PixiJS原始碼分析系列:第三章 使用 canvas 作為渲染器

池中物王二狗發表於2024-07-26

使用 canvasRenderer 渲染

上一章分析了一下 Sprite 在預設 webgl 渲染器上的渲染,這章讓我們把目光聚集到 canvasRenderer 上

image

使用 canvas 渲染器渲染圖片的 demo

要使用 canvas 作為渲染器,我們需要引用 pixi-legacy.js

/bundles/pixi.js-legacy/dist/pixi-legacy.js

像下面這樣先建一個簡單的 demo 用於測試:

<script src="/bundles/pixi.js-legacy/dist/pixi-legacy.js"></script>
<script type="text/javascript">
const app = new PIXI.Application({ width: 800, height: 600 , forceCanvas: true});  
document.body.appendChild(app.view);  

const rectangle = PIXI.Sprite.from('logo.png');  
rectangle.x = 100;  
rectangle.y = 100;  
rectangle.anchor.set(0.5);  
rectangle.rotation = Math.PI / 4;  
app.stage.addChild(rectangle);  
</script>

同樣建立一個簡單的載入顯示 logo 的 demo

執行它應該可以看到在第一章 simple.html 中一模一樣的一張 logo 圖被渲染在了網頁上

在 Application.ts 的 constructor 函式內,即 78 行新增 console.log(this.renderer); 輸出當前的渲染器看看

constructor(options?: Partial<IApplicationOptions>)
    {
        // The default options
        options = Object.assign({
            forceCanvas: false,
        }, options);

        this.renderer = autoDetectRenderer<VIEW>(options);
        console.log(this.renderer);
        // install plugins here
        Application._plugins.forEach((plugin) =>
        {
            plugin.init.call(this, options);
        });
    }

image

圖 3-1

圖 3-1 中可以發現輸出了一個 _CanvasRenderer2 的而不是 CanvasRenderer 例項,是因為其實在 demo 中載入的 pixi.js 是經過 rollup 編譯後的。

demo https://github.com/willian12345/blogpost/tree/main/analysis/PixiJS/pixijs-dev/examples/sprite-canvas.html

Sprite 類

Sprite 是 webgl 渲染器和 canvas 渲染器共用的

注意 Sprite.ts 類本身並不做渲染,儲存了 Sprite 的基本資訊

在此處最終渲染到 canvas 上用的是 CanvasSpriteRenderer 渲染類

我們在直接使用 html 的 canvas 繪製影像時,是直接呼叫 context.drawImage 方法,並傳遞一個“影像源”

但在 pixi.js 內,這個影像源並不是直接的影像或 canvas,而是先封裝成了一個 texture 即紋理物件,統一管理

找到 /packages/CanvasSpriteRenderer.ts 的第 37 - 40 行

static extension: ExtensionMetadata = {
    name: 'sprite',
    type: ExtensionType.CanvasRendererPlugin,
};

可以看到 CanvasSpriteRenderer 是一個渲染器的外掛,當需要渲染一個 sprite 的時候呼叫的是此外掛

最終被呼叫的 sprite 渲染方法, 即繪製圖片或路徑等到 canvas 上

render(sprite: Sprite): void
{
    const texture = sprite._texture;
    const renderer = this.renderer;
    const context = renderer.canvasContext.activeContext;
    const activeResolution = renderer.canvasContext.activeResolution;

    if (!texture.valid)
    {
        return;
    }

    const sourceWidth = texture._frame.width;
    const sourceHeight = texture._frame.height;

    let destWidth = texture._frame.width;
    let destHeight = texture._frame.height;

    if (texture.trim)
    {
        destWidth = texture.trim.width;
        destHeight = texture.trim.height;
    }

    let wt = sprite.transform.worldTransform;
    let dx = 0;
    let dy = 0;

    const source = texture.baseTexture.getDrawableSource();

    if (texture.orig.width <= 0 || texture.orig.height <= 0 || !texture.valid || !source)
    {
        return;
    }

    renderer.canvasContext.setBlendMode(sprite.blendMode, true);

    context.globalAlpha = sprite.worldAlpha;

    // If smoothingEnabled is supported and we need to change the smoothing property for sprite texture
    const smoothingEnabled = texture.baseTexture.scaleMode === SCALE_MODES.LINEAR;
    const smoothProperty = renderer.canvasContext.smoothProperty;

    if (smoothProperty
        && context[smoothProperty] !== smoothingEnabled)
    {
        context[smoothProperty] = smoothingEnabled;
    }

    if (texture.trim)
    {
        dx = (texture.trim.width / 2) + texture.trim.x - (sprite.anchor.x * texture.orig.width);
        dy = (texture.trim.height / 2) + texture.trim.y - (sprite.anchor.y * texture.orig.height);
    }
    else
    {
        dx = (0.5 - sprite.anchor.x) * texture.orig.width;
        dy = (0.5 - sprite.anchor.y) * texture.orig.height;
    }

    if (texture.rotate)
    {
        wt.copyTo(canvasRenderWorldTransform);
        wt = canvasRenderWorldTransform;
        groupD8.matrixAppendRotationInv(wt, texture.rotate, dx, dy);
        // the anchor has already been applied above, so lets set it to zero
        dx = 0;
        dy = 0;
    }

    dx -= destWidth / 2;
    dy -= destHeight / 2;

    renderer.canvasContext.setContextTransform(wt, sprite.roundPixels, 1);
    // Allow for pixel rounding
    if (sprite.roundPixels)
    {
        dx = dx | 0;
        dy = dy | 0;
    }

    const resolution = texture.baseTexture.resolution;

    const outerBlend = renderer.canvasContext._outerBlend;

    if (outerBlend)
    {
        context.save();
        context.beginPath();
        context.rect(
            dx * activeResolution,
            dy * activeResolution,
            destWidth * activeResolution,
            destHeight * activeResolution
        );
        context.clip();
    }

    if (sprite.tint !== 0xFFFFFF)
    {
        if (sprite._cachedTint !== sprite.tintValue || sprite._tintedCanvas.tintId !== sprite._texture._updateID)
        {
            sprite._cachedTint = sprite.tintValue;

            // TODO clean up caching - how to clean up the caches?
            sprite._tintedCanvas = canvasUtils.getTintedCanvas(sprite, sprite.tintValue);
        }

        context.drawImage(
            sprite._tintedCanvas,
            0,
            0,
            Math.floor(sourceWidth * resolution),
            Math.floor(sourceHeight * resolution),
            Math.floor(dx * activeResolution),
            Math.floor(dy * activeResolution),
            Math.floor(destWidth * activeResolution),
            Math.floor(destHeight * activeResolution)
        );
    }
    else
    {
        context.drawImage(
            source,
            texture._frame.x * resolution,
            texture._frame.y * resolution,
            Math.floor(sourceWidth * resolution),
            Math.floor(sourceHeight * resolution),
            Math.floor(dx * activeResolution),
            Math.floor(dy * activeResolution),
            Math.floor(destWidth * activeResolution),
            Math.floor(destHeight * activeResolution)
        );
    }

    if (outerBlend)
    {
        context.restore();
    }
    // just in case, leaking outer blend here will be catastrophic!
    renderer.canvasContext.setBlendMode(BLEND_MODES.NORMAL);
}

我想 sprite render 方法估計是在使用 pixi 時用的最多的方法

在此 render 方法內 打一個 debugger 後:

image

圖 3-2

看一下方法的呼叫棧 從 圖 3-2 中的紅色向上箭頭可以看到 _tick 函式一級一級往 render 方法內呼叫

render 函式做了什麼

render 方法大致做了以下幾步:

  1. 接受一個 sprite 物件例項,獲取到這個 sprite 的當前 "啟用的canvas2d上下文" activeContext

    當前啟用的上下文不是固定的“根上下文” rootContext 而是可變的,因為可以並允許建立多個 canvas 的情況存在比如 “離屏渲染,用新的canvas快取圖片” 等

    /packages/canvas-render/CanvasContextSystem.ts 檔案的第 79 行 init() 初始化方法內可以看到 this.activeContext = this.rootContext; 預設就是“根上下文”

  2. 接下來是確定當前 canvas context 的渲染模式 renderer.canvasContext.setBlendMode(sprite.blendMode, true);

    即根據傳遞進來的 sprite 的 blendMode 確定當前 canvas context 的渲染模式, blendMode 是一個列舉值

    blendMode 對應的是 可以檢視 /packages/canvas-render/src/utils/mapCanvasBlendModesToPixi.ts 中的生成並儲存的 CanvasRenderingContext2D.globalCompositeOperation 值

    具體值所對應的效果可檢視 https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation

    除了做特效外,碰到過最多blendMode 的應用場景是在一些 html5 做那種刮獎效果

  3. 生成當前 context 上下文的變幻矩陣(transform)

    根據傳遞進來的 sprite 的 texture 確定繪製“圖形”的尺寸,旋轉資訊,轉換成當前上下文的變幻矩陣(transform)

    render 方法內的 'wt' 變數(word transform) , 就是這一句 renderer.canvasContext.setContextTransform(wt, sprite.roundPixels, 1);

  4. 根據 outerBlend 確定是否需要上下文進行 clip 裁剪

    其實就是是否要用就遮罩效果 相關資訊可檢視 https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Compositing

  5. 呼叫上下文的 canvas 原生方法 drawImage 開始真正的繪製工作, 這裡的 tint 值比較有意思,後面再詳細介紹它

    繪製前先判斷之前是否有快取過圖形,如果之前繪製過就直接繪製快取的圖形以提高效能

    此處作者還註釋了一句,// TODO clean up caching - how to clean up the caches? , 充分說明了寫程式肯定不是一蹴而就的 _!

用 tint 給顯示物件(DisplayObject)上色

tint 屬性用於改變顯示物件的顏色。

它透過混合原始顏色與指定的顏色來給顯示物件

這裡有幾個關鍵點來理解 tint 屬性的工作方式:

  1. 顏色混合:tint接受一個十六進位制顏色值,這個值用來與物件原有的顏色進行混合。混合操作不是簡單地替換顏色,而是基於色彩理論進行的,因此可以得到不同的視覺效果。

  2. 透明度影響:tint操作同時影響顏色和alpha(透明度)。這意味著,即使不直接改變物件的透明度,顏色的變化也可能影響其視覺上的透明程度。

  3. 全白或全透明不受影響:如果一個畫素是完全白色(#FFFFFF)或完全透明,那麼tint不會改變它。這是因為全白畫素可以吸收任何顏色的混合,而全透明畫素則不顯示顏色變化。

  4. 多邊形和紋理:對於包含紋理的顯示物件(如Sprite),tint會影響整個紋理的顏色。而對於向量圖形(如透過Graphics繪製的形狀),顏色混合會直接應用於線條或填充顏色。

  5. 效能考慮:與直接更換紋理或顏色相比使用 tint 在很多情況下更高效,因為它避免了重新載入或建立新的紋理資源。

在這裡判斷是否需要處理 tint 快取比較有意思,如果你將 render 內的 sprite.tint 用 console.log 輸出會得到值 16777215

而用 console.log(0xFFFFFF) 輸出的也是 16777215, 都會轉換成十進位制

以我們這個 sprite-canvas.html 為例,它是不快取的,所以會直接走最下面的直接繪製邏輯

如果把我們的 sprite-canvas.html 程式碼修改一下,加一句 rectangle.tint = 'red'; 如下:

<script type="text/javascript">
const app = new PIXI.Application({ width: 800, height: 600 , forceCanvas: true});  
document.body.appendChild(app.view);  

const rectangle = PIXI.Sprite.from('logo.png');  
rectangle.x = 100;  
rectangle.y = 100;  
rectangle.anchor.set(0.5);  
rectangle.rotation = Math.PI / 4;  
rectangle.tint = 'red';
app.stage.addChild(rectangle);  
</script>

你可以看到,整個 PixiJS 的 logo 變成了絕色

image

圖 3-3

更準確的說應該說是混合成了紅色

在 render 函式內的 sprite._tintedCanvas = canvasUtils.getTintedCanvas(sprite, sprite.tintValue);

/packages/canvas-render/src/canvasUtils.ts 原始檔 50 行找到 的 getTintedCanvas 方法

此方法內會呼叫 canvasUtils.tintMethod(texture, color, canvas);

canvasUtils.tintMethod = canvasUtils.canUseMultiply ? canvasUtils.tintWithMultiply : canvasUtils.tintWithPerPixel;

最後根據能不能使用 multiply 確定使用哪種 tint 方法,優先使用 tintWithMultiply 方法

/packages/canvas-render/src/canvasUtils.ts 原始檔 110 - 159 行:

tintWithMultiply: (texture: Texture, color: number, canvas: ICanvas): void =>
{
    const context = canvas.getContext('2d');
    const crop = texture._frame.clone();
    const resolution = texture.baseTexture.resolution;

    crop.x *= resolution;
    crop.y *= resolution;
    crop.width *= resolution;
    crop.height *= resolution;

    canvas.width = Math.ceil(crop.width);
    canvas.height = Math.ceil(crop.height);

    context.save();
    context.fillStyle = Color.shared.setValue(color).toHex();

    context.fillRect(0, 0, crop.width, crop.height);

    context.globalCompositeOperation = 'multiply';

    const source = texture.baseTexture.getDrawableSource();

    context.drawImage(
        source,
        crop.x,
        crop.y,
        crop.width,
        crop.height,
        0,
        0,
        crop.width,
        crop.height
    );

    context.globalCompositeOperation = 'destination-atop';

    context.drawImage(
        source,
        crop.x,
        crop.y,
        crop.width,
        crop.height,
        0,
        0,
        crop.width,
        crop.height
    );
    context.restore();
},

在 tintWithMultiply 這個方法透過設定 context 上下文的 fillStyle 結合 globalCompositeOperation 繪製一個矩形框疊加到影像源(source)上 實現變色,當然這裡使用的肯定是獨立於 rootContext 的 canvas

context.fillStyle = Color.shared.setValue(color).toHex();

context.fillRect(0, 0, crop.width, crop.height);

context.globalCompositeOperation = 'multiply';

如果不支援 Multiply 則呼叫效能消耗更高的 tintWithPerPixel 方法

tintWithPerPixel: (texture: Texture, color: number, canvas: ICanvas): void =>
{
    const context = canvas.getContext('2d');
    const crop = texture._frame.clone();
    const resolution = texture.baseTexture.resolution;

    crop.x *= resolution;
    crop.y *= resolution;
    crop.width *= resolution;
    crop.height *= resolution;

    canvas.width = Math.ceil(crop.width);
    canvas.height = Math.ceil(crop.height);

    context.save();
    context.globalCompositeOperation = 'copy';
    context.drawImage(
        texture.baseTexture.getDrawableSource(),
        crop.x,
        crop.y,
        crop.width,
        crop.height,
        0,
        0,
        crop.width,
        crop.height
    );
    context.restore();

    const [r, g, b] = Color.shared.setValue(color).toArray();
    const pixelData = context.getImageData(0, 0, crop.width, crop.height);

    const pixels = pixelData.data;

    for (let i = 0; i < pixels.length; i += 4)
    {
        pixels[i + 0] *= r;
        pixels[i + 1] *= g;
        pixels[i + 2] *= b;
    }

    context.putImageData(pixelData, 0, 0);
},

注意 tintWithPerPixel 這個方法內是先繪製源影像,再利用 getImageData 和 putImageData 畫素級操作實現的變色效果,所以傳統比較消耗效能

在 render 方法的最後一句 renderer.canvasContext.setBlendMode(BLEND_MODES.NORMAL); 將上下文的渲染模式恢復為普通值,以免影響全域性的渲染

至上 canvas-sprite 渲染流程算是走完了

本章小節

果然 canvas 的渲染比起 webgl 的渲染容易理解一些,雖然都是順序執行的命令列,但是 webgl 的渲染模式需要繪製到 GPU 之前需要收集的命令比 canvas 渲染要多出許多步驟

下一章讓我們聚焦到最重要的事件互動上,PixiJS 是如何在 canvas 上實現互動事件的,如何處理最典型的滑鼠點選事件並響應點選

還有,如果你到現在還是沒能在你本地把調式專案跑起來,那麼首先參考這個系列文章的第一章,然後直接下載我這個 https://github.com/willian12345/blogpost/tree/main/analysis/PixiJS/pixijs-dev 調式專案


注:轉載請註明出處部落格園:王二狗Sheldon池中物 (willian12345@126.com)

相關文章