PixiJS编程指南之渲染与更新机制

1,034 阅读16分钟

前言

PixiJS 8.x 版本进行了架构上的重要更新,优化了渲染和更新机制,简化了动画管理,提升了性能和灵活性。PixiJS 8.x 的渲染和更新机制主要通过以下几个关键组件来实现:ApplicationRendererTickerEventSystem 等。这些组件共同作用,自动管理每一帧的更新和渲染循环,让开发者无需手动控制每一帧。下面将分别介绍ApplicationRendererTickerEventSystem,以及他们是如何协同工作的,从场景图的更新到渲染他们都做了什么。

1 Application

Application 是 PixiJS 的核心入口,用于创建渲染器、舞台(stage)以及帧更新的基础环境,代码量并不多,整合之后估计只有几十行代码。Application 自动管理了渲染器、更新机制和渲染循环,并内置了更新和渲染的调度功能。

  • 初始化渲染器和舞台:在 Application 创建时,会初始化 Renderer(负责渲染)和 Container(根容器)作为 stage
  • 启动自动渲染:默认情况下,Application 启动后会自动运行更新和渲染循环。

整个应用渲染的入口在这里,Application 启动后会自动运行更新和渲染循环,但是可以关闭。在创建Application 时可以通过配置参数autoStart来决定是否开启渲染循环。如果关闭渲染循环,就需要主动调用app.render来更新场景图。此外,从该类还可以获取canvas、view、screen对象。整体来说这个application类是非常简洁的。

2 扩展系统Extension

在Pixi中,扩展系统(Extension System) 作为一种新的功能,旨在增强 PixiJS 核心功能的灵活性和可扩展性。通过该系统,开发者可以以插件或扩展的形式,向 PixiJS 添加额外的功能,而无需修改核心代码。这种系统使得 PixiJS 的功能可以更容易地进行定制和扩展,适用于各种自定义需求,比如支持新的渲染效果、添加新的动画、拓展与外部库的集成等。

扩展extension是一个对象,并不是一个类,支持以下几种扩展:

/**
 * Collection of valid extension types.
 * @memberof extensions
 */
enum ExtensionType
// eslint-disable-next-line @typescript-eslint/indent
{
    /** extensions that are registered as Application plugins */
    Application = 'application',

    /** extensions that are registered as WebGL render pipes */
    WebGLPipes = 'webgl-pipes',
    /** extensions that are registered as WebGL render pipes adaptors */
    WebGLPipesAdaptor = 'webgl-pipes-adaptor',
    /** extensions that are registered as WebGL render systems */
    WebGLSystem = 'webgl-system',

    /** extensions that are registered as WebGPU render pipes */
    WebGPUPipes = 'webgpu-pipes',
    /** extensions that are registered as WebGPU render pipes adaptors */
    WebGPUPipesAdaptor = 'webgpu-pipes-adaptor',
    /** extensions that are registered as WebGPU render systems */
    WebGPUSystem = 'webgpu-system',

    /** extensions that are registered as Canvas render pipes */
    CanvasSystem = 'canvas-system',
    /** extensions that are registered as Canvas render pipes adaptors */
    CanvasPipesAdaptor = 'canvas-pipes-adaptor',
    /** extensions that are registered as Canvas render systems */
    CanvasPipes = 'canvas-pipes',

    /** extensions that combine the other Asset extensions */
    Asset = 'asset',
    /** extensions that are used to load assets through Assets */
    LoadParser = 'load-parser',
    /** extensions that are used to resolve asset urls through Assets */
    ResolveParser = 'resolve-parser',
    /** extensions that are used to handle how urls are cached by Assets */
    CacheParser = 'cache-parser',
    /** extensions that are used to add/remove available resources from Assets */
    DetectionParser = 'detection-parser',

    /** extensions that are registered with the MaskEffectManager */
    MaskEffect = 'mask-effect',

    /** A type of extension for creating a new advanced blend mode */
    BlendMode = 'blend-mode',

    /** A type of extension that will be used to auto detect a resource type */
    TextureSource = 'texture-source',

    /** A type of extension that will be used to auto detect an environment */
    Environment = 'environment',

    /** A type of extension for building and triangulating custom shapes used in graphics. */
    ShapeBuilder = 'shape-builder',

    /** A type of extension for creating custom batchers used in rendering. */
    Batcher = 'batcher',
}

重要属性

/** @ignore */
    _addHandlers: {} as Partial<Record<ExtensionType, ExtensionHandler>>,

    /** @ignore */
    _removeHandlers: {} as Partial<Record<ExtensionType, ExtensionHandler>>,

    /** @ignore */
    _queue: {} as Partial<Record<ExtensionType, StrictExtensionFormat[]>>,

_queue用于收集扩展类型,及其对应的扩展插件;

_addHandlers用于添加对应扩展类型的hander,当再次添加同类型的扩展时就会调用hander,举例如下:

extensions.handleByList(ExtensionType.Application, Application._plugins);

我们通过extensions.handleByList,第二个参数是数组,来收集该类型对应插件,如果插件已经被注册,那么就会添加到对应数组Application._plugins里面,同时会有一个默认的handler记录在_addHandlers,用于给Application._plugins添加插件 , 如果对应扩展类型插件没有注册,那么当对应类型插件注册时,就会直接调用这个handler直接添加该插件。

添加插件

/**
     * Register new extensions with PixiJS.
     * @param extensions - The spread of extensions to add to PixiJS.
     * @returns {extensions} For chaining.
     */
    add(...extensions: Array<ExtensionFormat | any>)
    {
        // Handle any extensions either passed as class w/ data or as data
        extensions.map(normalizeExtension).forEach((ext) =>
        {
            ext.type.forEach((type) =>
            {
                const handlers = this._addHandlers;
                const queue = this._queue;

                if (!handlers[type])
                {
                    queue[type] = queue[type] || [];
                    queue[type]?.push(ext);
                }
                else
                {
                    handlers[type]?.(ext);
                }
            });
        });

        return this;
    },

从这里可以看出,这个extension的一个作用就是给对应类型应用添加插件,而整个渲染管线需要涉及到的环节(扩展类型应用)都会依赖于这些插件进行处理场景图的渲染,这也就是为什么这个extension可以为开发者扩展他们自己的渲染方式。

3 Ticker

Ticker 是PixiJS一个重要组件,用于管理帧循环、动画和更新逻辑,整个Pixi的场景图更新与渲染都是从这里开始。

3.1 基础用法

创建 Ticker 实例

import { Ticker } from 'pixi.js';

const ticker = new Ticker();

ticker.add((deltaTime) => {
    console.log(`Delta time: ${deltaTime}`);
    // 执行更新逻辑
});

// 开始运行 Ticker
ticker.start();

全局共享 Ticker

Ticker有两个全局共享ticker,shared主要用于渲染和更新。

static get shared(): Ticker
    {
        if (!Ticker._shared)
        {
            const shared = Ticker._shared = new Ticker();

            shared.autoStart = true;
            shared._protected = true;
        }

        return Ticker._shared;
    }

system主要用于驱动视觉动画和渲染的计时器。

static get system(): Ticker
    {
        if (!Ticker._system)
        {
            const system = Ticker._system = new Ticker();

            system.autoStart = true;
            system._protected = true;
        }

        return Ticker._system;
    }

3.2 主要属性和方法

属性

  1. deltaTime
    当前帧和上一帧的时间差,以逻辑帧(通常 1/60 秒)为单位。
  2. deltaMS
    当前帧和上一帧的时间差,以毫秒为单位。
  3. speed
    用于调节 Ticker 更新速度的属性。默认值为 1,调高或调低该值可以改变更新频率:
ticker.speed = 2; // 加快两倍速度
ticker.speed = 0.5; // 减慢到一半速度
  1. started
    表示 Ticker 是否正在运行的布尔值。

方法

  1. add(fn, context, priority?)
    向 Ticker 添加回调函数。可选参数 context 设置回调的上下文,priority 定义优先级(数值越低,优先级越高)。
ticker.add((deltaTime) => console.log('Tick 1'), null, -10);
ticker.add((deltaTime) => console.log('Tick 2'), null, 10);

add回调函数会在每一个动画帧调用ticker.update的时候,从链表头部开始依次执行场景图的更新任务:

while (listener)
{
    listener = listener.emit(this);
}

2. remove(fn)
移除指定回调:

const update = (deltaTime) => console.log('Updating...');
ticker.add(update);
ticker.remove(update);
  1. start()
    启动 Ticker。若已经运行,不会重复启动。
  2. stop()
    停止 Ticker,暂停所有回调执行。

ticker通常是会与TickerListener配合使用,当ticker出发之后,就会查看有哪些监听ticker的监听器,这个监听器就是负责整个Pixi场景图的更新与渲染。

再来看一下ticker是如何进行渲染循环的

constructor()
    {
        this._head = new TickerListener(null, null, Infinity);
        this.deltaMS = 1 / Ticker.targetFPMS;
        this.elapsedMS = 1 / Ticker.targetFPMS;

        this._tick = (time: number): void =>
        {
            this._requestId = null;

            if (this.started)
            {
                // Invoke listeners now
                this.update(time);
                // Listener side effects may have modified ticker state.
                if (this.started && this._requestId === null && this._head.next)
                {
                    this._requestId = requestAnimationFrame(this._tick);
                }
            }
        };
    }

可以看到,这个ticker是依赖于浏览器的requestAnimationFrame,在每一帧的时候触发渲染循环。

3.3 TickerListener

TickerListener 代表了添加到 Ticker 的一个回调函数对象。通过 Tickeradd 方法注册的每个回调实际上都会生成一个对应的 TickerListener 实例。它能够:

  • 按优先级控制回调的执行顺序。
  • 管理回调的上下文绑定_context。
  • 提供开关以启用或禁用特定回调_fn。

我们来看一下这个ticker是如何添加TickerListener

/**
     * Internally adds the event handler so that it can be sorted by priority.
     * Priority allows certain handler (user, AnimatedSprite, Interaction) to be run
     * before the rendering.
     * @private
     * @param listener - Current listener being added.
     * @returns This instance of a ticker
     */
    private _addListener(listener: TickerListener): this
    {
        // For attaching to head
        let current = this._head.next;
        let previous = this._head;

        // Add the first item
        if (!current)
        {
            listener.connect(previous);
        }
        else
        {
            // Go from highest to lowest priority
            while (current)
            {
                if (listener.priority > current.priority)
                {
                    listener.connect(previous);
                    break;
                }
                previous = current;
                current = current.next;
            }

            // Not yet connected
            if (!listener.previous)
            {
                listener.connect(previous);
            }
        }

        this._startIfPossible();

        return this;
    }

如果当前添加的listener的优先级大于当前绑定的监听器的优先级,那么就会进行listener.connect(previous)

/**
     * Connect to the list.
     * @param previous - Input node, previous listener
     */
    public connect(previous: TickerListener): void
    {
        this.previous = previous;
        if (previous.next)
        {
            previous.next.previous = this;
        }
        this.next = previous.next;
        previous.next = this;
    }

可以发现,这个connect作用就是将需要链接的监听器绑定到当前监听器的previous,并将当前监听器绑定到previous监听器的next,从这里也可以看出,这个TickerListener实际上就是一个链表,当ticker触发之后,就会从链表header开始依次执行监听器绑定的回调函数。

在这之前,当我发现这个Pixi的ticker依赖于requestAnimationFrame的时候,当时就产生一个疑问,Pixi是如何控制场景图的更新是在渲染之前的,其答案就在这个TickerListener链表上面,整个场景图的渲染是在链表的尾部,也就是说,当场景图更新完毕之后,最后才会进行渲染。

4 render

Renderer 是 PixiJS 中的渲染核心。它根据提供的 DisplayObject (如 ContainerSprite)构建场景图,执行绘制操作,将图像最终渲染到屏幕上的 <canvas> 或 WebGL 渲染上下文中。

PixiJS 的 Renderer 支持以下两种渲染模式:

  1. WebGL 渲染模式:默认使用现代 WebGL(如 WebGL 2 或 WebGL 1)进行硬件加速渲染。
  2. Canvas 渲染模式:作为回退方案,当 WebGL 不可用时启用。

4.1 Renderer 的类型

1. WebGLRenderer

  • 默认的渲染器,使用 WebGL 进行硬件加速。
  • 适合处理复杂场景和高性能需求。

2. CanvasRenderer

  • 在不支持 WebGL 的环境中启用,例如一些旧浏览器。
  • 功能受限,但可以保证基本的绘图需求。

4.2 Renderer 的核心功能

1. 渲染方法

renderer.render(displayObject, options?)

Renderer 的主方法,负责将场景树中的 DisplayObject 渲染到屏幕上。

const app = new PIXI.Application();
const sprite = PIXI.Sprite.from('example.png');
app.stage.addChild(sprite);

// 手动渲染
app.renderer.render(app.stage);
  • 参数:
    • displayObject:需要渲染的根对象(通常是 ContainerStage)。
    • options:渲染选项,例如是否清理缓冲区或自定义矩阵。

2. 管理渲染管线

其 提供了更灵活的渲染管线支持,允许开发者扩展和自定义渲染行为。

  • 中间件:可以通过注册中间件来扩展渲染器的功能。
  • 渲染插件:支持添加自定义渲染逻辑,适用于高级用户。

3. 渲染目标

Renderer 支持将渲染内容输出到以下目标:

  • 默认 Canvas:渲染到 <canvas> 元素,用于直接显示。
  • 离屏缓冲:渲染到 RenderTexture,可以用于生成贴图或实现特效。
const renderTexture = PIXI.RenderTexture.create({ width: 256, height: 256 });
renderer.render(sprite, { renderTexture });

4.3 Renderer 的属性

**1. **view

  • 关联的 HTML <canvas> 元素,用于渲染输出。
  • 你可以将其添加到 DOM 树中。
document.body.appendChild(renderer.view);

**2. width 和 **height

  • 渲染器的宽度和高度,影响渲染输出的分辨率。

**3. **backgroundColor

  • 设置场景的背景颜色,使用 16 进制表示法。

4.4 Render初始化做了什么事

1、Constructor初始化SystemRunner

SystemRunner是什么呢?我们看一下Pixi对SystemRunner的介绍:

SystemRunner is used internally by the renderers as an efficient way for systems to

be notified about what the renderer is up to during the rendering phase.

翻译过来就是

SystemRunner被渲染器在内部使用,在渲染阶段通知渲染器正在做什么。简单来说就是通过流水线的方式来进行场景图的渲染,其会涉及到很多环节,而这些环节通过SystemRunner来进行管理。

以下是默认的SystemRunner

const defaultRunners = [
    'init',
    'destroy',
    'contextChange',
    'resolutionChange',
    'reset',
    'renderEnd',
    'renderStart',
    'render',
    'update',
    'postrender',
    'prerender'
] as const;

这些默认SystemRunner会在render的constructor中添加到render的runners中

constructor(config: RendererConfig)
    {
        super();
        this.type = config.type;
        this.name = config.name;
        this.config = config;

        const combinedRunners = [...defaultRunners, ...(this.config.runners ?? [])];

        this._addRunners(...combinedRunners);
        // Validation check that this environment support `new Function`
        this._unsafeEvalCheck();
    }

2 render.init

之后会调用render中的init对render进行初始化

/**
     * Initialize the renderer.
     * @param options - The options to use to create the renderer.
     */
    public async init(options: Partial<OPTIONS> = {})
    {
        const skip = options.skipExtensionImports === true ? true : options.manageImports === false;

        await loadEnvironmentExtensions(skip);

        this._addSystems(this.config.systems);
        this._addPipes(this.config.renderPipes, this.config.renderPipeAdaptors);

        // loop through all systems...
        for (const systemName in this._systemsHash)
        {
            const system = this._systemsHash[systemName];

            const defaultSystemOptions = (system.constructor as any).defaultOptions;

            options = { ...defaultSystemOptions, ...options };
        }

        options = { ...AbstractRenderer.defaultOptions, ...options };
        this._roundPixels = options.roundPixels ? 1 : 0;

        // await emits..
        for (let i = 0; i < this.runners.init.items.length; i++)
        {
            await this.runners.init.items[i].init(options);
        }

        // store options
        this._initOptions = options as OPTIONS;
    }

loadEnvironmentExtensions用于加载当前环境对应的扩展,

_addSystems用于添加System,默认需要参与的System如下:

const DefaultWebGLSystems = [
    ...SharedSystems,
    GlUboSystem,
    GlBackBufferSystem,
    GlContextSystem,
    GlBufferSystem,
    GlTextureSystem,
    GlRenderTargetSystem,
    GlGeometrySystem,
    GlUniformGroupSystem,
    GlShaderSystem,
    GlEncoderSystem,
    GlStateSystem,
    GlStencilSystem,
    GlColorMaskSystem,
];

看一下render是如何添加的:

/**
     * Add a new system to the renderer.
     * @param ClassRef - Class reference
     * @param name - Property name for system, if not specified
     *        will use a static `name` property on the class itself. This
     *        name will be assigned as s property on the Renderer so make
     *        sure it doesn't collide with properties on Renderer.
     * @returns Return instance of renderer
     */
    private _addSystem(ClassRef: SystemConstructor, name: string): this
    {
        const system = new ClassRef(this as unknown as Renderer);

        if ((this as any)[name])
        {
            throw new Error(`Whoops! The name "${name}" is already in use`);
        }

        (this as any)[name] = system;

        this._systemsHash[name] = system;

        for (const i in this.runners)
        {
            this.runners[i].add(system);
        }

        return this;
    }

SystemRunner

public add(item: unknown): this
    {
        if ((item as any)[this._name])
        {
            this.remove(item);
            this.items.push(item);
        }

        return this;
    }

(item as any)[this._name]这个表示,定义的System是否跟含有到SystemRunner中的字段,

也就是说SystemRunner用于收集那些涉及到该SystemRunner相关的System,这个System是什么呢?

随便看一个GlShaderSystem

/**
 * System plugin to the renderer to manage the shaders for WebGL.
 * @memberof rendering
 */
export class GlShaderSystem implements ShaderSystem

根据Pixi对GlShaderSystem这个类的描述可以知道,该类作为一个系统插件,帮助渲染器来管理WebGL的着色器。

也就是说整个渲染系统是依赖于SystemRunner运行,不同的SystemRunner都会关联对应的系统插件,当SystemRunner运行任务时,就会分配给对应的系统插件进行。

再看: this._addPipes(this.config.renderPipes, this.config.renderPipeAdaptors);

这段代码的意思就是添加渲染管线,看一下有哪些通用的渲染管线:

export const SharedRenderPipes = [
    BlendModePipe,
    BatcherPipe,
    SpritePipe,
    RenderGroupPipe,
    AlphaMaskPipe,
    StencilMaskPipe,
    ColorMaskPipe,
    CustomRenderPipeGraphicsPipe
];

渲染管线会被挂在到render,而render又会被挂载到每一个系统插件上,这样再系统插件运行任务时,就可以获取到对应的渲染管线,获取对应的管线去进行渲染对应的图元。

private _addPipes(pipes: RendererConfig['renderPipes'], pipeAdaptors: RendererConfig['renderPipeAdaptors']): void
    {
        const adaptors = pipeAdaptors.reduce((acc, adaptor) =>
        {
            acc[adaptor.name] = adaptor.value;

            return acc;
        }, {} as Record<string, any>);

        pipes.forEach((pipe) =>
        {
            const PipeClass = pipe.value;
            const name = pipe.name;

            const Adaptor = adaptors[name];

            // sorry typescript..
            (this.renderPipes as any)[name] = new PipeClass(
                this as unknown as Renderer,
                Adaptor ? new Adaptor() : null
            );
        });
    }

在添加渲染管线时,还会给渲染管线匹配对应的适配器,这个所谓的适配器实际上就是用于指定图元的渲染,比如说GlGraphicsAdaptor,这个GlGraphicsAdaptor就是用于渲染Pixi的graphics数据的。

4.5 render.render

public render(args: RenderOptions | Container, deprecated?: {renderTexture: any}): void
    {
        let options = args;

        if (options instanceof Container)
        {
            options = { container: options };

            if (deprecated)
            {
                // #if _DEBUG
                // eslint-disable-next-line max-len
                deprecation(v8_0_0, 'passing a second argument is deprecated, please use render options instead');
                // #endif

                options.target = deprecated.renderTexture;
            }
        }

        options.target ||= this.view.renderTarget;

        // TODO: we should eventually fix events so that it can handle multiple canvas elements
        if (options.target === this.view.renderTarget)
        {
            // TODO get rid of this
            this._lastObjectRendered = options.container;
            options.clearColor = this.background.colorRgba;
        }

        if (options.clearColor)
        {
            const isRGBAArray = Array.isArray(options.clearColor) && options.clearColor.length === 4;

            options.clearColor = isRGBAArray ? options.clearColor : Color.shared.setValue(options.clearColor).toArray();
        }

        if (!options.transform)
        {
            options.container.updateLocalTransform();
            options.transform = options.container.localTransform;
        }

        this.runners.prerender.emit(options);
        this.runners.renderStart.emit(options);
        this.runners.render.emit(options);
        this.runners.renderEnd.emit(options);
        this.runners.postrender.emit(options);
    }

上述代码就是WebGLRenderer的渲染方法,可以发现其依赖于runners运行系统,而每种类型的运行系统都有相关的系统插件与之关联,通过系统插件完成最终的渲染行为。

5 Shader与Program

在WebGL中我们知道 一个 Program 必须包含一个顶点着色器和一个片段着色器。 Shader 是 Program 的组件,而 Program 是 Shader 的执行容器。 Shader 被编译后,Program 会将它们链接在一起形成一个完整的 GPU 执行管线。

Pixi中shader的作用如下:

  • 编写自定义顶点和片段着色器代码。
  • 创建特殊的图形效果,如渐变、光照、纹理操作等。
  • 为对象绑定自定义的渲染逻辑。

Pixi中的shader在创建的时候需要绑定Program,这个Program会包含顶点着色器和片元着色器字符串程序代码,这个Program是Pixi用于管理顶点着色器和片元着色器的类,并不是由于WebGL创建的,shader会绑定这个由Pixi生成的Program, 最终会根据shader及其Program通过WebGL来创建Program,并绑定着色器。

6 通过Graphics案例分析从图形创建到图形渲染的整个流程Pixi都做了什么

通过代码示例来分析整个渲染流程,同时回答以下几个问题:

  • 渲染数据如何生成?
  • buffer数据如何绑定?
  • shader如何生成?
  • program如何生成?
  • WebGL如何渲染?

先上代码:

import { Application, Graphics } from "pixi.js";

(async () => {
  const app = new Application();
  await app.init({ antialias: true, resizeTo: window });
  document.body.appendChild(app.canvas);

  const graphics = new Graphics();

  // 矩形
  graphics.rect(50, 50, 100, 100);
  graphics.fill(0xde3249);

  // 矩形 + 线型,pixi支持线型
  graphics.rect(350, 50, 100, 100);
  graphics.fill(0xc34288);
  graphics.stroke({ width: 10, color: 0xffbd01 });

  // 圆
  graphics.circle(100, 250, 50);
  graphics.fill(0xde3249, 1);

  // 圆 + 线型
  graphics.circle(400, 250, 50);
  graphics.fill(0xc34288, 1);
  graphics.stroke({ width: 10, color: 0xffbd01 });

  // 椭圆 + 线型
  graphics.ellipse(600, 250, 80, 50);
  graphics.fill(0xaa4f08, 1);
  graphics.stroke({ width: 2, color: 0xffffff });

  // 连续自由绘制
  graphics.moveTo(50, 350);
  graphics.lineTo(250, 350);
  graphics.lineTo(100, 400);
  graphics.lineTo(50, 350);
  graphics.fill(0xff3300);
  graphics.stroke({ width: 4, color: 0xffd900 });

  // Draw a rounded rectangle
  graphics.roundRect(50, 440, 100, 100, 16);
  graphics.fill(0x650a5a, 0.25);
  graphics.stroke({ width: 2, color: 0xff00ff });

  // 五角星
  graphics.star(360, 370, 5, 50);
  graphics.fill(0x35cc5a);
  graphics.stroke({ width: 2, color: 0xffffff });

  // 七角星
  graphics.star(280, 510, 7, 50);
  graphics.fill(0xffcc5a);
  graphics.stroke({ width: 2, color: 0xfffffd });

  // 绘制多段线
  const path = [600, 370, 700, 460, 780, 420, 730, 570, 590, 520];
  graphics.poly(path);
  graphics.fill(0x3500fa);

  app.stage.addChild(graphics);
})();

显示效果

6.1 分析上述代码流程:

  • 创建应用const app = new Application();
  • 初始化应用app.init这一步会给创建render,初始化render,并开启ticker渲染循环;
  • 创建一个graphics,graphics主要用于渲染基本形状,如线,圆和显示矩形,并对其着色和填充,在这里会一次性创建多个图元;

6.2 渲染流程

要回答这个问题,我们先理清这个渲染路线,首先由WebGLRenderer调用其render触发渲染任务,当执行 this.runners.render.emit(options);时,渲染开始。前面说过runners是代表系统运行任务,当前执行的是render任务,而这个系统运行任务又会交给其绑定的系统插件,而当前的render任务只绑定了一个RenderGroupSystem系统插件,运行render.emit(options)相当于会触发该系统插件的render任务,也就是说RenderGroupSystem调用自身的render进行渲染,其接受的是一个container,这个container是整个场景图的根节点。RenderGroupSystem当触发渲染任务之后,会交任务交给渲染管线,也就是GraphicsPipe这个用于处理Graphics渲染的类,GraphicsPipe会触发execute进行渲染Graphics数据,但是前面说过,渲染管线会绑定适配器,渲染任务同样会交给适配器来进行,这个适配器是在初始化render为渲染管线分配的,这就需要看适配器初始化做了什么。

下面来看适配器GlGraphicsAdaptor具体做了什么:

public init()
    {
        const uniforms = new UniformGroup({
            uColor: { value: new Float32Array([1, 1, 1, 1]), type: 'vec4<f32>' },
            uTransformMatrix: { value: new Matrix(), type: 'mat3x3<f32>' },
            uRound: { value: 0, type: 'f32' },
        });

        const maxTextures = getMaxTexturesPerBatch();

        const glProgram = compileHighShaderGlProgram({
            name: 'graphics',
            bits: [
                colorBitGl,
                generateTextureBatchBitGl(maxTextures),
                localUniformBitGl,
                roundPixelsBitGl,
            ]
        });

        this.shader = new Shader({
            glProgram,
            resources: {
                localUniforms: uniforms,
                batchSamplers: getBatchSamplersUniformGroup(maxTextures),
            }
        });
    }

通过GlGraphicsAdaptor的init方法,可以知道,GlGraphicsAdaptor在初始化的时候就会绑定着色器和程序,看一下这个程序glProgram是如何获取的、

export function compileHighShaderGlProgram({ bits, name }: {bits: HighShaderBit[], name: string}): GlProgram
{
    return new GlProgram({
        name,
        ...compileHighShaderGl({
            template: {
                vertex: vertexGlTemplate,
                fragment: fragmentGlTemplate,
            },
            bits: [
                globalUniformsBitGl,
                ...bits,
            ]
        })
    });
}

查看compileHighShaderGlProgram这个方法得知,会创建一个GlProgram实例,并同时通过compileHighShaderGl获取program,vertexGlTemplate与fragmentGlTemplate分别是Pixi默认的顶点着色器和片元着色器模板,而bits是什么呢?可以把他理解为钩子,因为在Pixi中这个所有图元都共享同一个着色器模板,但是不同图元肯定会有自己独特的着色器,那么如何处理不同图元需要配不同着色器呢?Pixi在着色器的生成中设计了一种钩子,将整个着色器的生成添加不同钩子,这样不同渲染场景只需要在创建program实例的时候添加匹配自己渲染场景的钩子即可,而这些钩子会被通过字符串匹配,链接的方式融入到着色器的生成中。

鉴于此我们可以回答**‘program如何生成?’这个问题,以及‘shader如何生成?’**这个问题,

从上面GlGraphicsAdaptor的init方法来看,似乎内部自己还创建了一个shader实例,那这个shader是干嘛的呢?看一下Pixi对它的描述:

/**
 * The Shader class is an integral part of the PixiJS graphics pipeline.
 * Central to rendering in PixiJS are two key elements: A [shader] and a [geometry].
 * The shader incorporates a {@link GlProgram} for WebGL or a {@link GpuProgram} for WebGPU,
 * instructing the respective technology on how to render the geometry.
 *
 * The primary goal of the Shader class is to offer a unified interface compatible with both WebGL and WebGPU.
 * When constructing a shader, you need to provide both a WebGL program and a WebGPU program due to the distinctions
 * between the two rendering engines. If only one is provided, the shader won't function with the omitted renderer.
 *
 * Both WebGL and WebGPU utilize the same resource object when passed into the shader.
 * Post-creation, the shader's interface remains consistent across both WebGL and WebGPU.
 * The sole distinction lies in whether a glProgram or a gpuProgram is employed.
 *
 * Modifying shader uniforms, which can encompass:
 *  - TextureSampler {@link TextureStyle}
 *  - TextureSource {@link TextureSource}
 *  - UniformsGroups {@link UniformGroup}
 * @example
 *
 * const shader = new Shader({
 *     glProgram: glProgram,
 *     gpuProgram: gpuProgram,
 *     resources: {
 *         uTexture: texture.source,
 *         uSampler: texture.sampler,
 *         uColor: [1, 0, 0, 1],
 *     },
 * });
 *
 * // update the uniforms
 * shader.resources.uColor[1] = 1;
 * shader.resources.uTexture = texture2.source;
 * @class
 * @memberof rendering
 */

翻译过来就是着色器包含一个{@link GlProgram}的WebGL或{@link GpuProgram}的WebGPU,指导如何渲染几何图形的相应技术。 Shader类的主要目标是提供一个兼容WebGL和WebGPU的统一接口。

我们回到GlGraphicsAdaptor,最后会调用GlGraphicsAdaptor的execute进行渲染,看一下execute做了什么?

    public execute(graphicsPipe: GraphicsPipe, renderable: Graphics): void
    {
        const context = renderable.context;
        const shader = context.customShader || this.shader;
        const renderer = graphicsPipe.renderer as WebGLRenderer;
        const contextSystem = renderer.graphicsContext;

        const {
            batcher, instructions,
        } = contextSystem.getContextRenderData(context);

        // WebGL specific..
        shader.groups[0] = renderer.globalUniforms.bindGroup;

        renderer.state.set(graphicsPipe.state);

        renderer.shader.bind(shader);

        renderer.geometry.bind(batcher.geometry, shader.glProgram);

        const batches = instructions.instructions as Batch[];

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

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

                renderer.geometry.draw('triangle-list', batch.size, batch.start);
            }
        }
    }

注意这个context,这个GraphicsContext是什么呢?,看一下

我们可以发现,这下面的instructions不就是我们之前的graphics进行绘图的操作吗,这些操作会通过GraphicsContext记录下来,通过GraphicsPath记录绘制路径,这就是我们进行 graphics.rect是记录的数据。

这些只是原始数据,并不是着色器需要的数据,接着往下看,contextSystem.getContextRenderData(context),

这个做了什么呢?从字面意思上看就是获取渲染数据,查看方法返回结果,

可知,这里会返回WebGL渲染是需要的顶点数据和索引数据,到这里就可以回答**‘渲染数据如何生成’**这个问题。那么可能会疑惑这个渲染数据是什么,其实就是着色器的顶点数据,和顶点索引,因为我们使用Pixi内部定义的图元类进行绘制图元,这个图元类都是需要通过底层的WebGL进行渲染的,也就是说需要进行接口的对接,包括我们如果要使用PixiJS扩展自己的渲染管线,同样需要去适配PixiJS。

再接着往下看 renderer.shader.bind(shader);

注意shader是由GlShaderSystem管理,从字面意思上看就是绑定着色器,看具体做了什么?

    /**
     * Changes the current shader to the one given in parameter.
     * @param shader - the new shader
     * @param skipSync - false if the shader should automatically sync its uniforms.
     * @returns the glProgram that belongs to the shader.
     */
    public bind(shader: Shader, skipSync?: boolean): void
    {
        this._setProgram(shader.glProgram);

        if (skipSync) return;

        defaultSyncData.textureCount = 0;
        defaultSyncData.blockIndex = 0;

        let syncFunction = this._shaderSyncFunctions[shader.glProgram._key];

        if (!syncFunction)
        {
            syncFunction = this._shaderSyncFunctions[shader.glProgram._key] = this._generateShaderSync(shader, this);
        }

        syncFunction(this._renderer, shader, defaultSyncData);
    }

首先会从给出的shader中绑定的program来创建一个WebGL的program,并创建顶点着色器和片元着色器,将顶点着色器和片元着色器绑定给WebGL的program,最终生成的program会被缓存到GlShaderSystem。

再接着看 renderer.geometry.bind(batcher.geometry, shader.glProgram);

这里会给WebGL绑定buffer,同样的,这个绑定的buffer会被GlBufferSystem缓存起来

    /**
     * Binds geometry so that is can be drawn. Creating a Vao if required
     * @param geometry - Instance of geometry to bind.
     * @param program - Instance of program to use vao for.
     */
    public bind(geometry?: Geometry, program?: GlProgram): void
    {
        // shader = shader || this.renderer.shader.shader;

        const gl = this.gl;

        this._activeGeometry = geometry;

        const vao = this.getVao(geometry, program);

        if (this._activeVao !== vao)
        {
            this._activeVao = vao;

            gl.bindVertexArray(vao);
        }

        this.updateBuffers();
    }

同时还会把顶点数据和索引数据给GlGeometrySystem

再看一下,顶点数据是如何被绑定到buffer的,

    /**
     * Activate vertex array object.
     * @param geometry - Geometry instance.
     * @param program - Shader program instance.
     */
    protected activateVao(geometry: Geometry, program: GlProgram): void
    {
        const gl = this._renderer.gl;

        const bufferSystem = this._renderer.buffer;
        const attributes = geometry.attributes;

        if (geometry.indexBuffer)
        {
            // first update the index buffer if we have one..
            bufferSystem.bind(geometry.indexBuffer);
        }

        let lastBuffer = null;

        // add a new one!
        for (const j in attributes)
        {
            const attribute = attributes[j];
            const buffer = attribute.buffer;
            const glBuffer = bufferSystem.getGlBuffer(buffer);
            const programAttrib = program._attributeData[j];

            if (programAttrib)
            {
                if (lastBuffer !== glBuffer)
                {
                    bufferSystem.bind(buffer);

                    lastBuffer = glBuffer;
                }

                const location = programAttrib.location;

                // TODO introduce state again
                // we can optimise this for older devices that have no VAOs
                gl.enableVertexAttribArray(location);

                const attributeInfo = getAttributeInfoFromFormat(attribute.format);

                const type = getGlTypeFromFormat(attribute.format);

                if (programAttrib.format?.substring(1, 4) === 'int')
                {
                    gl.vertexAttribIPointer(location,
                        attributeInfo.size,
                        type,
                        attribute.stride,
                        attribute.offset);
                }
                else
                {
                    gl.vertexAttribPointer(location,
                        attributeInfo.size,
                        type,
                        attributeInfo.normalised,
                        attribute.stride,
                        attribute.offset);
                }

                if (attribute.instance)
                {
                    // TODO calculate instance count based of this...
                    if (this.hasInstance)
                    {
                        // Can't use truthiness check to determine if divisor is set,
                        // since 0 is a valid value for divisor
                        const divisor = attribute.divisor ?? 1;

                        gl.vertexAttribDivisor(location, divisor);
                    }
                    else
                    {
                        throw new Error('geometry error, GPU Instancing is not supported on this device');
                    }
                }
            }
        }
    }

GlGeometrySystem的activateVao,激活顶点数据,将顶点数据绑定到buffer,到这里可以回答‘buffer数据如何绑定’这个问题。

一起准备就绪之后,就剩下渲染了,看一下GlGeometrySystem

  /**
     * Draws the currently bound geometry.
     * @param topology - The type primitive to render.
     * @param size - The number of elements to be rendered. If not specified, all vertices after the
     *  starting vertex will be drawn.
     * @param start - The starting vertex in the geometry to start drawing from. If not specified,
     *  drawing will start from the first vertex.
     * @param instanceCount - The number of instances of the set of elements to execute. If not specified,
     *  all instances will be drawn.
     */
    public draw(topology?: Topology, size?: number, start?: number, instanceCount?: number): this
    {
        const { gl } = this._renderer;
        const geometry = this._activeGeometry;

        const glTopology = topologyToGlMap[geometry.topology || topology];

        instanceCount ||= geometry.instanceCount;

        if (geometry.indexBuffer)
        {
            const byteSize = geometry.indexBuffer.data.BYTES_PER_ELEMENT;
            const glType = byteSize === 2 ? gl.UNSIGNED_SHORT : gl.UNSIGNED_INT;

            if (instanceCount > 1)
            {
                /* eslint-disable max-len */
                gl.drawElementsInstanced(glTopology, size || geometry.indexBuffer.data.length, glType, (start || 0) * byteSize, instanceCount);
                /* eslint-enable max-len */
            }
            else
            {
                /* eslint-disable max-len */
                gl.drawElements(glTopology, size || geometry.indexBuffer.data.length, glType, (start || 0) * byteSize);
                /* eslint-enable max-len */
            }
        }
        else if (instanceCount > 1)
        {
            // TODO need a better way to calculate size..
            gl.drawArraysInstanced(glTopology, start || 0, size || geometry.getSize(), instanceCount);
        }
        else
        {
            gl.drawArrays(glTopology, start || 0, size || geometry.getSize());
        }

        return this;
    }

GlGeometrySystem.draw负责渲染数据,其会调用WebGL的相关API,到这里就可以回答**WebGL如何渲染这个问题**了。

7 总结

本文详细介绍了PiixJS的Application类,Application是整个应用的入口,关联到Pixi的渲染循环和渲染舞台;齐次又消息说明了Pixi的渲染循环Ticker,Ticker通过requestAnimationFrame来管理整个帧循环,并可以自己设置每一帧的时间; 介绍了WebGLRenderer,WebGLRenderer负责舞台stage数据的渲染,并延申到了Pixi的运行系统SystemRunner,系统插件System以及渲染管线;介绍了Pixi着色器shader已经program如何生成的;并通过graphics案例贯穿了整个渲染流程。

整体来说,Pixi的渲染都是依赖于其渲染关系,不论是graphics还是其它几何模型,整个流程应该都是类似的,具体细节不做研究,如果感兴趣的话,可以看一下PixiJS源码