使用 HTML canvas 将多个蒙版图像叠加在一起

Multiple masked images on top of each other using HTML canvas

我是 canvas 的新手,之前没有使用过它,但我认为它非常适合以下任务。在处理它时我有疑问,我仍然不知道是否可以使用 canvas.

来实现该任务

Exemplary graphic of the masks and images and the result that I want to achieve (and the actual results that I got).

简化的伪代码示例:

const items = [1, 2];

for (let i = 0; i < items.length; i++) {
  ctx.drawImage(preloadedMask[i], x, y,  canvasWidth, canvasHeight);
  ctx.globalCompositeOperation = 'source-in';

  img[i] = new Image();
  img[i].onload = () => {
    ctx.drawImage(img[i], 0, 0, canvasWidth, canvasHeight);
    ctx.globalCompositeOperation = 'source-over';
    //ctx.globalCompositeOperation = 'source-out';
  };
  img[i].src = `images/${i+1}.jpg`;
}

当我删除 globalCompositeOperation 和图像时,蒙版会像我预期的那样完美地并排绘制。
但是一旦我添加了一个 globalCompositeOperation,它就变得复杂了,老实说我非常困惑。

我在 onload 回调中尝试了所有可能的 globalCompositeOperation 值 - 但变化不大。我想我必须在为每次迭代绘制掩码后将 globalCompositeOperation 更改为不同的值 - 但我没有想法。

是否有任何方法可以实现图中描述的所需输出,或者我应该放弃 canvas 来完成这项任务?

不幸的是,您想要实现的目标并不那么容易 - 至少如果您使用的 SVG 被视为图像并直接绘制到 canvas。

假设我们有以下 svg 蒙版和图像

如果我们获取第一个蒙版和第一张图像并使用以下代码:

context.drawImage(maskA,0,0,width,height);
context.globalCompositeOperation = "source-in";
context.drawImage(imageA,0,0,width,height);

我们得到了想要的输出:

如果我们重复这个过程并对第二个面具做同样的事情:

context.drawImage(maskB,0,0,width,height);
context.globalCompositeOperation = "source-in";
context.drawImage(imageB,0,0,width,height);

我们只会看到一个空 canvas。为什么?我们将 globalCompositeOperation 设置为 'source-in' 并且之前的 canvas 和第二个掩码 (maskB) 没有任何重叠区域。这意味着我们正在有效地擦除 canvas.

如果我们尝试补偿 save/restore 上下文或将 globalCompositeOperation 重置为其初始状态

context.save();
context.drawImage(maskA,0,0,width,height);
context.globalCompositeOperation = "source-in";
context.drawImage(imageA,0,0,width,height);
context.restore();
context.drawImage(maskB,0,0,width,height);
context.globalCompositeOperation = "source-in";
context.drawImage(imageB,0,0,width,height);

我们还没有成功:

所以这里的技巧是这样的:

  • 确保要屏蔽的 svg 和图像都已完全加载
  • 创建一个新的空canvas目标大小canvas
  • 将第一个蒙版画到新的 canvas
  • 将其 globalCompositeOperation 设置为 'source-in'
  • 将第一张图片绘制到新的 canvas
  • 将新的canvas画到目标canvas
  • 擦除新的 canvas 并重复前面的步骤来合成您的最终图像

举个例子(点击'Run code snippet'):

let canvas = document.getElementById("canvas");
let ctx = canvas.getContext("2d");
let imagesLoaded = 0;
let imageA = document.getElementById("imageA");
let imageB = document.getElementById("imageB");
let width = canvas.width;
let height = canvas.height;

function loaded() {
  imagesLoaded++;
  if (imagesLoaded == 4) {
    let tempCanvas = document.createElement("canvas");
    let tempContext = tempCanvas.getContext("2d");
    tempCanvas.width = width;
    tempCanvas.height = height;
    tempContext.save();
    tempContext.drawImage(document.getElementById("semiCircleA"), 0, 0, width, height);
    tempContext.globalCompositeOperation = "source-in";
    tempContext.drawImage(imageA, 0, 0, width, 160);
    ctx.drawImage(tempCanvas, 0, 0, width, height);

    tempContext.restore();
    tempContext.clearRect(0, 0, width, height);

    tempContext.drawImage(document.getElementById("semiCircleB"), 0, 0, width, height);
    tempContext.globalCompositeOperation = "source-in";
    tempContext.drawImage(imageB, 0, 0, width, height);
    ctx.drawImage(tempCanvas, 0, 0, width, height);
  }
}

document.getElementById("semiCircleA").onload = loaded;
document.getElementById("semiCircleB").onload = loaded;

imageA.onload = loaded;
imageA.src = "https://picsum.photos/id/237/160/160";

imageB.onload = loaded;
imageB.src = "https://picsum.photos/id/137/160/160";
<h1>Final Canvas</h1>
<canvas id="canvas" width=160 height=160>
</canvas>
<br>
<h1>Sources</h1>
<img id="semiCircleA" src='data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="160px" height="160px">
  <path d="M80,0 A80,80 0 0,0 80,160"/>
</svg>'>
<img id="semiCircleB" src='data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="160px" height="160px">
  <path d="M80,0 A80,80 0 0,1 80,160"/>
</svg>'>
<img id="imageA">
<img id="imageB">

一个canvas可以是一层

像任何元素一样,canvas 很容易创建,可以像图像一样处理,或者如果您熟悉 photoshop,canvas 可以是图层。

创建空白canvas

// Returns the renderable image (canvas)
function CreateImage(width, height) {
    return Object.assign(document.createElement("canvas"), {width, height});
}

复制 canvas 或图像之类的对象

// Image can be any image like element including canvas. Returns the renderable image 
function CopyImage(img, width = img.width, height = img.height, smooth = true) {
    const can = createImage(width, height});
    can.ctx = can.getContext("2d");
    can.ctx.imageSmoothingEnabled = smooth;
    can.ctx.drawImage(img, 0, 0, width, height);
    return can;
}

正在加载

切勿在渲染循环中加载图像。图片 onload 事件将不遵循您分配给 src 的顺序。因此 onload 中的图像渲染并不总是按照您希望的顺序进行。

加载所有图像并等待渲染。

加载一组图像的示例。函数 loadImages returns 承诺将在所有图像加载后解析。

const images = {
    maskA: "imageUrl",
    maskB: "imageUrl",
    imgA: "imageUrl",
    imgB: "imageUrl",
};
function loadImages(imgList, data) {
    return new Promise((done, loadingError) => {
        var count = 0;
        const imgs = Object.entries();
        for (const [name, src] of imgs) {
            imgList[name] = new Image;
            imgList[name].src = src;
            count ++;
            imgList[name].addEventListener("load", () => {
                    count--;
                    if (count === 0) { done({imgs: imgList, data}) }
                }, {once, true)
            );
            imgList[name].addEventListener("error", () => {
                    for (const [name, src] of imgs) { imgList[name] = src } 
                    loadingError(new Error("Could not load all images"));
                }, {once, true)
            );
        }
    });
}

渲染

最好创建函数来完成重复的任务。您正在重复的一项任务是遮罩,以下函数使用 canvas 作为目标、图像和遮罩

function maskImage(ctx, img, mask, x = 0, y = 0, w = ctx.canvas.height, h = ctx.canvas.width, clear = true) {
     ctx.globalCompositeOperation = "source-over";
     clear && ctx.clearRect(0, 0, ctx.canvas.height, ctx.canvas.width);
     ctx.drawImage(img, x, y, w, h);
     ctx.globalCompositeOperation = "destination-in";
     ctx.drawImage(mask, 0, 0, w, h);
     return ctx.canvas;  // return the renderable image 
}

一旦你设置了一些实用程序来帮助协调加载和渲染,你就可以合成你的最终结果

// assumes ctx is the context to render to
loadImages(images, {ctx}).then(({imgs, {ctx}} => {
    const w = ctx.canvas.width, h = ctx.canvas.height;
    ctx.clearRect(0, 0, w, h);
    const layer = copyImage(ctx.canvas);
    ctx.drawImage(maskImage(layer.ctx, imgs.imgA, imgs.maskA), 0, 0, w, h);
    ctx.drawImage(maskImage(layer.ctx, imgs.imgB, imgs.maskB), 0, 0, w, h);

    // if you no longer need the images always remove them from memory to avoid hogging
    // client's resources.
    imgs = {}; // de-reference images so that GC can clean up.

}

您现在可以根据需要对任意数量的蒙版图像进行分层。由于为每个子任务创建的函数,因此在本项目和未来的项目中都可以轻松创建更复杂的渲染,而无需编写冗长和重复的代码。