Skip to content

课程 10 - 图片导入导出

图片导入导出在无限画布中是一个非常重要的功能,通过图片产物可以和其他工具打通。因此虽然目前我们的画布绘制能力还很有限,但不妨提前考虑和图片相关的问题。在这节课中你将学习到以下内容:

  • 将画布内容导出成 PNG,JPEG 和 SVG 格式的图片
  • 在画布中渲染图片
  • 拓展 SVG 的能力,以 stroke-alignment 为例

将画布内容导出成图片

首先我们来看如何将画布内容导出成图片。Export from Figma 一文介绍了在 Figma 中如何通过切片工具将画布内容导出成包括 PNG 在内的多种格式图片。

export from figma

一些基于 Canvas2D 实现的图表库也提供了保存内容到图片的功能,下图来自 Highcharts,可以看到也提供了多种格式图片的导出功能,点击后会立刻触发浏览器的下载行为:

exporter in highcharts

我们希望实现如下导出功能:

  • 支持多种图片格式:PNG,JPEG 和 SVG
  • 支持指定裁剪区域
  • 支持选择是否包含网格

为此我们为这个图片导出器设计如下的使用方式:

ts
// 创建导出器
const exporter = new ImageExporter({
    canvas, // 传入画布
});

// 获取导出图片对应的 DataURL
const canvas = await exporter.toCanvas(); // HTMLCanvasElement
const dataURL = canvas.toDataURL(); // data:...

// 触发浏览器下载
exporter.downloadImage({
    dataURL,
    name: 'my-file',
});

但针对不同格式的图片略有差别,下面我们先介绍 PNG / JPEG 格式图片的导出方式。

导出 PNG / JPEG

HTMLCanvasElement.toDataURL() 可以获取画布内容对应的图片 DataURI,通过 type 参数可以指定图片格式,支持 PNG / JPEG 和 WebP。

js
var canvas = document.getElementById('canvas');
var dataURL = canvas.toDataURL(); // 默认为 PNG
console.log(dataURL);
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"

因此我们为图片导出器增加一个 toCanvas 方法。该方法用以将指定区域的画布内容绘制到额外的 HTMLCanvasElement 中,随后可以根据需要进一步加工,例如添加背景色、水印等。完整方法签名如下,值得注意的是该方法为异步,稍后我们会介绍原因:

ts
toCanvas(options: Partial<CanvasOptions> = {}): Promise<HTMLCanvasElement>;

interface CanvasOptions {
  grid: boolean;
  clippingRegion: Rectangle;
  beforeDrawImage: (context: CanvasRenderingContext2D) => void;
  afterDrawImage: (context: CanvasRenderingContext2D) => void;
}

各配置项含义如下:

  • grid 是否包含网格
  • clippingRegion 画布裁剪区域,用矩形表示
  • beforeDrawImage 在绘制画布内容前调用,适合绘制背景颜色
  • afterDrawImage 在绘制画布内容后调用,适合绘制水印

完整的使用示例如下:

ts
const canvas = await exporter.toCanvas({
    // 指定导出画布区域
    clippingRegion: new Rectangle(
        clippingRegionX,
        clippingRegionY,
        clippingRegionWidth,
        clippingRegionHeight,
    ),
    beforeDrawImage: (context) => {
        // 绘制背景色
        context.fillStyle = backgroundColor;
        context.fillRect(0, 0, clippingRegionWidth, clippingRegionHeight);
    },
    afterDrawImage: (context) => {
        // 绘制水印
        context.font = '24px Times New Roman';
        context.fillStyle = '#FFC82C';
        context.fillText('Watermark', 20, 20);
    },
});

我们在画布右上角放置了导出图片下拉菜单,如果对组件感兴趣可以参考:使用 Lit 和 Shoelace 开发 Web UI

js
$icCanvas = call(() => {
    return document.createElement('ic-canvas-lesson10');
});
js
call(() => {
    const { Canvas, Circle } = Lesson10;

    const stats = new Stats();
    stats.showPanel(0);
    const $stats = stats.dom;
    $stats.style.position = 'absolute';
    $stats.style.left = '0px';
    $stats.style.top = '0px';

    $icCanvas.parentElement.style.position = 'relative';
    $icCanvas.parentElement.appendChild($stats);

    $icCanvas.addEventListener('ic-ready', (e) => {
        const canvas = e.detail;

        for (let i = 0; i < 100; i++) {
            const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
                Math.random() * 255,
            )},${Math.floor(Math.random() * 255)})`;
            const circle = new Circle({
                cx: Math.random() * 600,
                cy: Math.random() * 400,
                r: Math.random() * 40,
                fill,
            });
            canvas.appendChild(circle);

            circle.addEventListener('pointerenter', () => {
                circle.fill = 'red';
            });
            circle.addEventListener('pointerleave', () => {
                circle.fill = fill;
            });
        }
    });

    $icCanvas.addEventListener('ic-frame', (e) => {
        stats.update();
    });
});

现在让我们来回答为何 toCanvas 方法要设计成异步。由于 WebGL / WebGPU 使用 SwapChain 双缓冲机制,在创建上下文时,preserveDrawingBuffer 的默认值为 false,因此需要确保调用 toDataURL 时渲染没有被清除(例如调用 gl.clear()),否则就会得到一张空白图片。我们在插件系统endFrame 钩子中添加如下逻辑,一旦接收到导出命令,就立刻在当前帧结束前保存画布内容,以防下一帧到来时被清空,这会导致该行为变成异步。

ts
hooks.endFrame.tap(() => {
    this.#device.endFrame();

    // capture here since we don't preserve drawing buffer
    if (this.#enableCapture && this.#resolveCapturePromise) {
        const { type, encoderOptions } = this.#captureOptions;
        const dataURL = (
            this.#swapChain.getCanvas() as HTMLCanvasElement
        ).toDataURL(type, encoderOptions);
        this.#resolveCapturePromise(dataURL);
        this.#enableCapture = false;
        this.#captureOptions = undefined;
        this.#resolveCapturePromise = undefined;
    }
});

另外可以选择导出图片是否包含 网格。暂时我们没有使用到裁剪以及水印等后续加工相关的功能。由于实现类似 Figma 的“切片”功能需要配合框选交互,后续实现时会一同介绍。现在让我们回到另一种特殊格式的图片。

导出 SVG

相比位图,矢量图的优势体现在:

  • 无限缩放不失真。这意味着它们可以被无限放大或缩小而不会失去清晰度,非常适合需要多种分辨率的场合。
  • 可编辑性。SVG 是文本文件,可以使用任何文本编辑器进行编辑,便于修改图形的属性和样式。
  • 对于复杂图形具有更小的文件大小。

因此设计工具一定都会提供对于这种格式的转换支持。对于我们的无限画布,可以将问题转换成:如何将场景图序列化,包括其中每一个节点的绘制属性、变换等。至于序列化的格式,除了 JSON,由于我们的 2D 图形的绘制属性设计本身就大量参考 SVG 实现,因此导出成 SVG 就十分自然了。

有趣的是,在 Three.js 中也提供了 toJSON 对场景的当前状态(包括对象、变换、材质等)保存成 JSON-Object-Scene-format-4。甚至还包括一个 SVGRenderer 能在有限条件下(无复杂 shading、阴影)尽可能渲染 3D 图形。

从图形到序列化节点

从场景图的根节点开始遍历,递归对子元素调用。对于 transform 这样拥有复杂结构值(position/scale/rotation)的属性需要进一步处理:

ts
function serializeNode(node: Shape): SerializedNode {
    const [type, attributes] = typeofShape(node);
    const data: SerializedNode = {
        type,
        attributes,
    };
    data.attributes.transform = serializeTransform(node.transform);
    data.children = node.children.map(serializeNode);
    return data;
}

以下面的 Circle 为例,我们得到了它的序列化对象:

js
serializedCircle = call(() => {
    const { Circle, serializeNode } = Lesson10;
    const circle = new Circle({
        cx: 100,
        cy: 100,
        r: 50,
        fill: 'red',
    });
    circle.transform.position.x = 100;
    return serializeNode(circle);
});

通过反序列化方法就可以将它导入画布,deserializeNode 根据序列化节点的 type 属性创建对应图形,为绘图属性赋值:

js
canvas2 = (async () => {
    const { Canvas } = Lesson10;

    const canvas = await Utils.createCanvas(Canvas, 200, 200);

    let id;
    const animate = () => {
        canvas.render();
        id = requestAnimationFrame(animate);
    };
    animate();

    unsubscribe(() => {
        cancelAnimationFrame(id);
        canvas.destroy();
    });

    return canvas;
})();
js
call(async () => {
    const { deserializeNode } = Lesson10;
    const circle = await deserializeNode(serializedCircle);
    canvas2.root.appendChild(circle);
    return canvas2.getDOM();
});

整个画布的序列化和反序列化方法只要对根节点应用即可,当然事件监听器是无法被序列化的:

ts
const json = JSON.stringify(serializeNode(canvas.root)); // {}
canvas.root = (await deserializeNode(JSON.parse(json))) as Group;

从序列化节点到 SVGElement

此时将序列化后的节点转换为 SVG Element 就很容易了。我们增加如下工具方法:

ts
export function toSVGElement(node: SerializedNode): SVGElement;

大部分属性例如 fill / stroke / opacity 都是 SVG 的同名属性,因此可以直接使用 setAttribute 进行赋值,但仍有一些特殊的属性需要特殊处理,例如:

  • transform 我们使用了 @pixi/math 中的 Transform 对象,需要它的 position / rotation / scale 转换成 matrix()
  • transform-origin 对应 transform 中的 pivot 属性
  • innerShadow 并不存在 SVG 同名属性,需要使用 filter 实现
  • outerShadow 同上

下面的例子展示了一个序列化后的圆转换成 <circle> 的效果,为了能在 HTML 页面中展示需要嵌入 <svg> 中,它的尺寸和画布保持一致:

js
call(() => {
    const { toSVGElement } = Lesson10;

    const $circle = toSVGElement(serializedCircle);
    const $svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    $svg.setAttribute('width', '200');
    $svg.setAttribute('height', '200');
    $svg.appendChild($circle);

    return $svg;
});

从 SVGElement 到序列化节点

很自然的,我们也需要一个从 SVGElement 到序列化节点的转换方法:

ts
export function fromSVGElement(element: SVGElement): SerializedNode;

但需要注意这种转换并不完全可逆,例如后续我们会介绍到的 StrokeAlignment 实现 中,一个 Circle 对应了两个 <circle>。在 Figma 中也是如此,下图展示了导入一个此前导出的 Ellipse 效果,可以看出两个图形并不完全一致。

import an exported ellipse in Figma

有趣的是,@pixi-essentials/svg 也试图将 SVG 元素转换成 Pixi.js 中的图形并绘制,详见 Vector rendering of SVG content with PixiJS

SVG 中的场景图

还有一点需要注意,在我们的场景图中任意图形都可以添加子节点,但 SVG 中只有 <g> 才可以添加子元素,除此之外例如 <circle> 是无法拥有子元素的。解决办法也很简单,对于拥有子节点的非 Group 元素,生成 SVG 时在外面套一个 <g>,将原本应用在本身的 transform 应用在它上面。假设后续我们支持了渲染文本,一个拥有文本子节点的 Circle 对应的 SVG 如下:

html
<g transform="matrix(1,0,0,0,1,0)">
    <circle cx="100" cy="100" r="100" fill="red" />
    <text />
</g>

inner & outerShadow

参考 Creating inner shadow in svg

导出作为填充的图片

虽然我们还没有介绍填充图片的实现,但如果直接将 fill 属性的 Image 值序列化将得到如下结果:

html
<circle fill="[object ImageBitmap]" />

这显然是无法正常展示的,我们需要将 Image 对象序列化成 DataURL,只要将图片绘制到 <canvas> 中,就可以利用上一节我们介绍过的 toDataURL 方法。参考 Convert ImageBitmap to Blob,我首先尝试了 ImageBitmapRenderingContext,ImageBitmap 可以在不阻塞主线程的情况下异步解码图像,这有助于提高应用的性能和响应性。

ts
async function imageBitmapToURL(bmp: ImageBitmap) {
    const canvas = document.createElement('canvas');
    // resize it to the size of our ImageBitmap
    canvas.width = bmp.width;
    canvas.height = bmp.height;
    // get a bitmaprenderer context
    const ctx = canvas.getContext('bitmaprenderer');
    ctx.transferFromImageBitmap(bmp);
    const blob = await new Promise<Blob>((res) => canvas.toBlob(res));
    return canvas.toDataURL();
}

但很不幸我们得到了如下报错。原因是我们已经使用了这个 ImageBitmap 创建了纹理,就无法再将它的控制权转移给新的 <canvas> 了。

CAUTION

The input ImageBitmap has been detached

因此我们只能使用常规的 drawImage 方式将图片绘制到 <canvas> 中。

绘制网格

最后来看如何使用 SVG 实现网格。参考 How to draw grid using HTML5 and canvas or SVG,我们使用 <pattern> 平铺实现:对于直线网格使用一大一小两组;对于圆点网格分别在“砖块”的四个角上各放置一个圆形:

然后在绘制网格逻辑处加入判断:非截图模式下保持不变,截图模式下仅当开启包含网格选项时才绘制:

ts
hooks.beginFrame.tap(() => {
    if (
        !this.#enableCapture ||
        (this.#enableCapture && this.#captureOptions.grid)
    ) {
        this.#grid.render(this.#device, this.#renderPass, this.#uniformBuffer);
    }
});

导出 PDF

现在像素和矢量图都有了,如果还想导出成 PDF 可以使用 jsPDF,它提供了添加图片的 API,限于篇幅这里就不介绍了。

下面让我们来看另一个话题,如何在画布中渲染一张图片。

在画布中渲染图片

在 WebGL / WebGPU 中通常需要加载图片并将其作为纹理使用,由于加载不同类型的资源是一个复杂的异步过程,大部分渲染引擎都会提供一个资源加载器。通常还会支持除不同类型图片之外的其他数据类型,例如音频、JSON、glTF 等。下面是 PIXI Assets 的使用例子:

ts
import { Sprite, Assets } from 'pixi.js';
// load the texture we need
const texture = await Assets.load('bunny.png');

// This creates a texture from a 'bunny.png' image
const bunny = new Sprite(texture);

那么如何实现一个资源加载器呢?

图片加载器

loaders.gl 针对不同类型的资源提供了一系列加载器,例如:

  • 可视化应用中常用的 JSON、CSV、GeoJSON、地理信息瓦片等
  • 3D 模型格式例如 glTF 等
  • 各种压缩纹理格式,使用 CompressedTextureLoader 在 WebWorker 中进行加载解析

这些加载器为上层应用的开发提供了极大便利,我们可以直接使用 ImageLoader,它支持这些图片格式:PNG, JPEG, GIF, WEBP, AVIF, BMP, SVG。使用方式如下,这里也能看出 loaders.gl 的设计思路,@loaders.gl/core 保证了 API 调用方式的统一以及不同类型加载器的可扩展性:

ts
import { ImageLoader } from '@loaders.gl/images';
import { load } from '@loaders.gl/core';

const image = await load(url, ImageLoader, options);

除了直接设置 fill 属性,在反序列化时也需要将 DataURL 还原成 Image 对象:

ts
async function deserializeNode(data: SerializedNode) {
    // data:image/png:base64...
    if (fill && isString(fill) && isDataUrl(fill)) {
        shape.fill = (await load(fill, ImageLoader)) as ImageBitmap;
    }
}

API 设计

回到我们的 API 设计部分,我们当然可以仿照之前的 Circle / Ellipse / Rect 为图片新增一种图形,对应 SVG 的 <image>

ts
const image = new Image({
    x: 0,
    y: 0,
    width: 100,
    height: 100,
    src: 'data:image...',
});

但仔细想想 Image 应该拥有 Rect 的全部能力,例如描边、圆角、阴影等等。有趣的是在 Figma 中如果我们选择向画布中插入一张原尺寸 1920 * 1920 的图片并导出成 SVG,会得到一个 <rect> 元素,它的结构如下(省略部分属性值),fill 属性引用了一个 <pattern>,间接使用了图片平铺填充:

html
<svg>
    <rect width="500" height="500" fill="url(#pattern0_2442_3)" />
    <defs>
        <pattern id="pattern0_2442_3">
            <use xlink:href="#image0_2442_3"
            <!-- 0.000520833 = 1 / 1920 -->
            transform="matrix(0.000520833 0 0 0.000527058 0 -0.0059761)" />
        </pattern>
        <image
            id="image0_2442_3"
            width="1920"
            height="1920"
            xlink:href="data:image/png;base64,iVBO..."
        />
    </defs>
</svg>

这给了我们一点启发,图片没必要以图形的形式单独存在,只要其他图形的 fill 属性支持贴图就好了,这样 Circle / Rect 等都可以使用图片作为填充。试想我们需要实现一个带描边的圆形图标需求,按照原本的设计需要一个 Image 图形搭配类似 <clipPath> 才能实现,现在只需要在已有 Circle 图形上填充图片:

ts
circle.fill = image;
circle.stroke = 'black';

实现

因此第一步我们将 fill 支持的类型从颜色字符串扩展到更多纹理来源:

ts
export interface IRenderable {
    fill: string; 
    fill: string | TexImageSource; 
}

type TexImageSource =
    | ImageBitmap
    | ImageData
    | HTMLImageElement
    | HTMLCanvasElement
    | HTMLVideoElement
    | OffscreenCanvas
    | VideoFrame;

在顶点数据中需要一个字段表示是否使用了纹理,如果使用就对纹理进行采样,这里的 SAMPLER_2D() 并非标准 GLSL 语法,而是我们自定义的标记,用于在 Shader 编译阶段替换成 GLSL100 / GLSL300 / WGSL 的采样语法。另外,目前纹理是上传的图片,后续还可以支持使用 Canvas2D API 创建的渐变例如 createLinearGradient

glsl
// vert
out vec2 v_Uv;
v_Uv = (a_FragCoord * radius / size + 1.0) / 2.0;

// frag
in vec2 v_Uv;
uniform sampler2D u_Texture;

if (useFillImage) {
    fillColor = texture(SAMPLER_2D(u_Texture), v_Uv);
}

使用了 uniform 会打破我们之前的合批处理逻辑。Inside PixiJS: Batch Rendering System 一文介绍了 Pixi.js 的 BatchRenderer 实现逻辑,从下面运行时编译的 Shader 模版可以看出,最大可以同时支持一组 %count% 个采样器,每个实例通过顶点数据 aTextureId 在采样器组中进行选择。

glsl
// Shader template in Pixi.js BatchRenderer
// vert
attribute int aTextureId;
varying int vTextureId;
vTextureId = aTextureId;

// frag
uniform sampler2D uSamplers[%count%];
varying int vTextureId;

简单起见,我们将合批逻辑简化为:

  1. fill 取值为颜色字符串与 Image 的无法合批
  2. fill 取值为不同 Image 的无法合批

因此下面的三个 Circle 会在同一批绘制。

js
$icCanvas3 = call(() => {
    return document.createElement('ic-canvas-lesson10');
});
js
call(() => {
    const { Canvas, Circle } = Lesson10;

    const stats = new Stats();
    stats.showPanel(0);
    const $stats = stats.dom;
    $stats.style.position = 'absolute';
    $stats.style.left = '0px';
    $stats.style.top = '0px';

    $icCanvas3.parentElement.style.position = 'relative';
    $icCanvas3.parentElement.appendChild($stats);

    $icCanvas3.addEventListener('ic-ready', async (e) => {
        const image = await Utils.loadImage(
            'https://infinitecanvas.cc/canvas.png',
        );

        const canvas = e.detail;

        const circle1 = new Circle({
            cx: 200,
            cy: 200,
            r: 50,
            fill: image,
            stroke: 'black',
            strokeWidth: 20,
            strokeOpacity: 0.5,
            strokeAlignment: 'inner',
        });
        canvas.appendChild(circle1);

        const circle2 = new Circle({
            cx: 320,
            cy: 200,
            r: 50,
            fill: image,
            stroke: 'black',
            strokeWidth: 20,
            strokeOpacity: 0.5,
        });
        canvas.appendChild(circle2);

        const circle3 = new Circle({
            cx: 460,
            cy: 200,
            r: 50,
            fill: image,
            stroke: 'black',
            strokeWidth: 20,
            strokeOpacity: 0.5,
            strokeAlignment: 'outer',
        });
        canvas.appendChild(circle3);
    });

    $icCanvas3.addEventListener('ic-frame', (e) => {
        stats.update();
    });
});

增加缓存

之前我们一直没有考虑类似 Program、Bindings、Sampler 等这些 GPU 对象的缓存问题。为此我们增加一个资源缓存管理器以实现复用,依据资源类型分别实现命中逻辑。以 Sampler 为例,当 SamplerDescriptor 中的属性完全一致时就会命中缓存,比较逻辑在 samplerDescriptorEquals 中。

ts
import { samplerDescriptorEquals } from '@antv/g-device-api';

export class RenderCache {
    device: Device;
    private samplerCache = new HashMap<SamplerDescriptor, Sampler>(
        samplerDescriptorEquals,
        nullHashFunc,
    );

    createSampler(descriptor: SamplerDescriptor): Sampler {
        // 优先从缓存中取
        let sampler = this.samplerCache.get(descriptor);
        if (sampler === null) {
            // 未命中,创建并添加缓存
            sampler = this.device.createSampler(descriptor);
            this.samplerCache.add(descriptor, sampler);
        }
        return sampler;
    }
}

增强 SVG: Stroke alignment

最后我们来引入一个有趣的话题。我们可以实现目前 SVG 规范还不支持的特性。

先来感受下 SVG 中 opacity stroke-opacityfill-opacity 的区别。下图左边的圆应用了 opacity="0.5",右边应用了 fill-opacity="0.5" stroke-opacity="0.5"。可以看出 stroke 描边有一半在圆内,一半在圆外:

在 Figma 中对应的 Stroke 位置的取值为 Center,其他可选值包括 InsideOutside,下图分别展示了这三种取值的效果。在 SVG 中名为 stroke-alignment,但目前停留在草案阶段,详见 Specifying stroke alignment

Stroke align in Figma

我们为所有图形增加 strokeAlignment 属性:

ts
export interface IRenderable {
    strokeAlignment: 'center' | 'inner' | 'outer'; 
}

渲染部分实现

在 Shader 部分的实现只需要区分这三种取值,按不同方式混合填充和描边色:

glsl
if (strokeAlignment < 0.5) { // center
    d1 = distance + strokeWidth;
    d2 = distance + strokeWidth / 2.0;
    color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1);
    color = mix_border_inside(strokeColor, color, d2);
} else if (strokeAlignment < 1.5) { // inner
    d1 = distance + strokeWidth;
    d2 = distance;
    color = mix_border_inside(over(fillColor, strokeColor), fillColor, d1);
    color = mix_border_inside(strokeColor, color, d2);
} else if (strokeAlignment < 2.5) { // outer
    d2 = distance + strokeWidth;
    color = mix_border_inside(strokeColor, color, d2); // No need to use fillColor at all
}

下面是我们的实现效果,可以看出渲染效果和 Figma 一致:

js
$icCanvas2 = call(() => {
    return document.createElement('ic-canvas-lesson10');
});
js
call(() => {
    const { Canvas, Circle } = Lesson10;

    const stats = new Stats();
    stats.showPanel(0);
    const $stats = stats.dom;
    $stats.style.position = 'absolute';
    $stats.style.left = '0px';
    $stats.style.top = '0px';

    $icCanvas2.parentElement.style.position = 'relative';
    $icCanvas2.parentElement.appendChild($stats);

    $icCanvas2.addEventListener('ic-ready', (e) => {
        const canvas = e.detail;

        const circle1 = new Circle({
            cx: 200,
            cy: 200,
            r: 50,
            fill: '#F67676',
            stroke: 'black',
            strokeWidth: 20,
            strokeOpacity: 0.5,
            strokeAlignment: 'inner',
        });
        canvas.appendChild(circle1);

        const circle2 = new Circle({
            cx: 320,
            cy: 200,
            r: 50,
            fill: '#F67676',
            stroke: 'black',
            strokeWidth: 20,
            strokeOpacity: 0.5,
        });
        canvas.appendChild(circle2);

        const circle3 = new Circle({
            cx: 460,
            cy: 200,
            r: 50,
            fill: '#F67676',
            stroke: 'black',
            strokeWidth: 20,
            strokeOpacity: 0.5,
            strokeAlignment: 'outer',
        });
        canvas.appendChild(circle3);
    });

    $icCanvas2.addEventListener('ic-frame', (e) => {
        stats.update();
    });
});

在计算渲染包围盒和拾取判定时,也需要考虑该属性。下面的函数反映了不同取值下,描边应该从图形本身向外延伸多少距离。

ts
function strokeOffset(
    strokeAlignment: 'center' | 'inner' | 'outer',
    strokeWidth: number,
) {
    if (strokeAlignment === 'center') {
        return strokeWidth / 2;
    } else if (strokeAlignment === 'inner') {
        return 0;
    } else if (strokeAlignment === 'outer') {
        return strokeWidth;
    }
}

导出 SVG 部分实现

正如前文提到的那样,SVG 目前还不支持 stroke-alignment,因此目前只能通过 hack 手段模拟。如果图形比较简单,完全可以分两次绘制填充和描边。下面为 Figma 对 stroke-alignment: 'inner' 的导出结果,也采用了这种方式:

html
<circle cx="200" cy="200" r="200" fill="#F67676" />
<circle
    cx="200"
    cy="200"
    r="160"
    stroke="black"
    stroke-opacity="0.5"
    stroke-width="100"
/>

除此之外,Figma 官网文档在 StrokeAlign in Figma widget 一文中还给出了另一种思路,不需要创建两个同类元素。How to simulate stroke-align (stroke-alignment) in SVG 尝试了这一思路,放大描边宽度至原来的两倍,配合 clipPath 和 mask 将多余部分剔除掉:

Inside and outside stroke are actually implemented by doubling the stroke weight and masking the stroke by the fill. This means inside-aligned stroke will never draw strokes outside the fill and outside-aligned stroke will never draw strokes inside the fill.

出于实现简单考虑,我们选择第一种方式,创建两个同类元素分别绘制填充和描边,可以在上面的示例中尝试导出 SVG。

随着画布功能的丰富,我们有必要引入测试来验证导入导出功能是否正常、渲染效果是否正确、UI 组件是否可交互。在下一节中我们将介绍它。

扩展阅读

Released under the MIT License.