Lição 10 - Importar e exportar imagens
Importar e exportar imagens é um recurso muito importante no Infinite Canvas e, por meio da imagem exportada, é possível se integrar a outras ferramentas. Então, embora as capacidades de desenho da nossa tela ainda sejam limitadas, vale pensar com antecedência nas questões relacionadas a imagens. Nesta lição, você vai aprender o seguinte:
- Exportar o conteúdo da tela para PNG, JPEG e SVG, e exportar efeitos baseados em tempo para GIF
- Renderizar imagens na tela, incluindo
object-fit/object-positionem preenchimentos de imagem - Estender as capacidades do SVG, usando
stroke-alignmentcomo exemplo.
Exportar o conteúdo da tela para imagem
Primeiro, vamos ver como exportar o conteúdo de uma tela para uma imagem. O artigo Export from Figma descreve como exportar uma tela para vários formatos, inclusive PNG, usando a ferramenta de fatia no Figma.
Algumas bibliotecas de gráficos baseadas em implementações Canvas2D também oferecem a capacidade de salvar o conteúdo em uma imagem. A imagem abaixo é do Highcharts, e você pode ver que ela também oferece a capacidade de exportar imagens em vários formatos, o que dispara o comportamento de download do navegador imediatamente após o clique:

Gostaríamos de implementar a funcionalidade de exportação a seguir:
- Suporte a vários formatos de imagem: PNG, JPEG e SVG.
- Suporte a especificar a área de recorte
- Suporte a escolher se uma grade é incluída ou não
Para isso, projetamos o exportador de imagem para ser usado da seguinte forma:
const exporter = new ImageExporter({
canvas,
});
const canvas = await exporter.toCanvas(); // HTMLCanvasElement
const dataURL = canvas.toDataURL(); // data:...
exporter.downloadImage({
dataURL,
name: 'my-file',
});Porém, há uma diferença “ligeira” entre os diferentes formatos de imagem. Abaixo, primeiro apresentamos a forma de exportar imagens no formato PNG / JPEG.
Exportar PNG / JPEG
HTMLCanvasElement.toDataURL() pode obter a imagem correspondente ao conteúdo da tela como DataURI. Pelo parâmetro type é possível especificar o formato da imagem, com suporte a PNG / JPEG e WebP.
var canvas = document.getElementById('canvas');
var dataURL = canvas.toDataURL(); // 默认为 PNG
console.log(dataURL);
// "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNby
// blAAAADElEQVQImWNgoBMAAABpAAFEI8ARAAAAAElFTkSuQmCC"Então adicionamos um método toCanvas ao exportador de imagem. Esse método serve para desenhar o conteúdo da tela da área especificada em um HTMLCanvasElement adicional, que depois pode ser manipulado conforme a necessidade, por exemplo adicionando uma cor de fundo, uma marca d'água e assim por diante. A assinatura completa do método está abaixo. Vale notar que esse método é assíncrono, e vamos explicar o motivo mais adiante:
toCanvas(options: Partial<CanvasOptions> = {}): Promise<HTMLCanvasElement>;
interface CanvasOptions {
grid: boolean;
clippingRegion: Rectangle;
beforeDrawImage: (context: CanvasRenderingContext2D) => void;
afterDrawImage: (context: CanvasRenderingContext2D) => void;
}O significado de cada item de configuração é o seguinte:
gridSe uma grade deve ser incluídaclippingRegionA região de recorte da tela, representada por um retângulo.beforeDrawImageChamado antes de desenhar o conteúdo da tela, adequado para desenhar a cor de fundo.afterDrawImageChamado depois de desenhar o conteúdo da tela, adequado para desenhar marcas d'água.
O exemplo completo de uso é o seguinte:
const canvas = await exporter.toCanvas({
clippingRegion: new Rectangle(
clippingRegionX,
clippingRegionY,
clippingRegionWidth,
clippingRegionHeight,
),
beforeDrawImage: (context) => {
// Customize background-color
context.fillStyle = backgroundColor;
context.fillRect(0, 0, clippingRegionWidth, clippingRegionHeight);
},
afterDrawImage: (context) => {
// Watermark
context.font = '24px Times New Roman';
context.fillStyle = '#FFC82C';
context.fillText('Watermark', 20, 20);
},
});Colocamos o menu suspenso Exportar imagem no canto superior direito da tela. Se tiver interesse nos componentes, consulte: Interface web com Lit e Shoelace.
$icCanvas = call(() => {
return document.createElement('ic-canvas-lesson10');
});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();
});
});Agora vamos responder por que o método toCanvas foi projetado para ser assíncrono. Como WebGL / WebGPU usam o mecanismo de buffer duplo da SwapChain, e o valor padrão de preserveDrawingBuffer é false ao criar o contexto, você precisa garantir que a renderização não seja limpa (por exemplo, chamando gl.clear()) ao chamar toDataURL; caso contrário, você obtém uma imagem em branco. Adicionamos a lógica a seguir ao hook endFrame em Arquitetura baseada em plugins para salvar o conteúdo da tela assim que o comando de exportação é recebido, antes do fim do quadro atual, para o caso de ele ser limpo quando o próximo quadro chegar, o que tornaria o comportamento assíncrono.
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;
}
});Além disso, você pode escolher se inclui ou não a Grade na imagem exportada. Por enquanto, não usamos as funções de recorte e de marca d'água no processamento posterior. Como a implementação de um recurso de “fatiamento” parecido com o do Figma exige uma interação de seleção por caixa, ela será apresentada em uma implementação posterior. Agora vamos voltar a outro formato de imagem especial.
Exportar SVG
As vantagens dos gráficos vetoriais em relação aos bitmaps são:
- Escala infinita sem distorção. Isso significa que eles podem ser ampliados ou reduzidos indefinidamente sem perder nitidez, o que os torna ideais para situações em que várias resoluções são necessárias.
- Editabilidade: SVGs são arquivos de texto que podem ser editados com qualquer editor de texto, o que facilita modificar os atributos e o estilo do gráfico.
- SVGs são arquivos de texto que podem ser editados com qualquer editor de texto, o que facilita modificar os atributos e os estilos dos gráficos.
Portanto, as ferramentas de design precisam oferecer suporte à conversão para esse formato. Para a nossa tela infinita, podemos converter o problema em: Como serializar o grafo de cena, incluindo os atributos de desenho de cada nó, as transformações e assim por diante. Quanto ao formato da serialização, além de JSON, como o projeto das propriedades de desenho dos nossos gráficos 2D em si referencia bastante as implementações SVG, é natural exportar para SVG.
Curiosamente, o Three.js também oferece toJSON para salvar o estado atual da cena (incluindo objetos, transformações, materiais etc.) como JSON-Object-Scene-format-4. Ele até inclui um SVGRenderer para renderizar gráficos 3D da melhor forma possível sob condições limitadas (sem sombreamento complexo, sombras).
De Shape para SerializedNode
Muitas ferramentas de quadro branco também oferecem formatos personalizados de serialização de gráficos, como JSON objects in tldraw e JSON schema in excalidraw.
Percorrido a partir do nó raiz do grafo de cena, chamado de forma recursiva nos elementos filhos. Atributos como transform, com valores estruturais complexos (position/scale/rotation), exigem processamento adicional:
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;
}Tome o Circle abaixo como exemplo: obtemos o objeto serializado dele:
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);
});Ele pode ser importado para a tela pelo método de desserialização. deserializeNode cria o gráfico correspondente com base no atributo type do nó serializado, atribuindo valores aos atributos de desenho:
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;
})();call(async () => {
const { deserializeNode } = Lesson10;
const circle = await deserializeNode(serializedCircle);
canvas2.root.appendChild(circle);
return canvas2.getDOM();
});Os métodos de serialização e desserialização da tela inteira podem ser aplicados apenas ao nó raiz, mas, é claro, os listeners de evento não podem ser serializados:
const json = JSON.stringify(serializeNode(canvas.root)); // {}
canvas.root = (await deserializeNode(JSON.parse(json))) as Group;De SerializedNode para SVGElement
Neste ponto, é fácil converter o nó serializado em SVG Element. Adicionamos o método utilitário a seguir:
export function toSVGElement(node: SerializedNode): SVGElement;A maioria dos atributos, como fill / stroke / opacity, são atributos SVG de mesmo nome, então podem ser atribuídos diretamente usando setAttribute, mas há alguns atributos especiais que precisam ser tratados de forma especial, por exemplo:
transformUsamos o objetoTransformem@pixi/math, e precisamos queposition / rotation / scalesejam convertidos paramatrix().transform-origincorresponde ao atributopivotemtransform.innerShadownão tem a propriedade SVG de mesmo nome; precisa ser implementado com um filtro.outerShadowO mesmo que acima
O exemplo a seguir mostra o efeito de um círculo serializado convertido em um <circle>, que precisa ser embutido em <svg> para ser exibido em uma página HTML, com as mesmas dimensões da tela:
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;
});De SVGElement para SerializedNode
Naturalmente, também precisamos de um método de conversão de SVGElement para SerializedNode:
export function fromSVGElement(element: SVGElement): SerializedNode;Observe que essa conversão não é totalmente reversível. Por exemplo, na implementação do StrokeAlignment, que veremos mais adiante, um Circle corresponde a dois <circle>. O mesmo acontece no Figma, em que a imagem a seguir mostra a importação de um efeito de Ellipse exportado anteriormente, e você pode ver que as duas formas não são idênticas.

@pixi-essentials/svg também quer converter SVGElement em formas no Pixi.js; veja Vector rendering of SVG content with PixiJS.
Grafo de cena no SVG
Outro ponto a observar é que, embora qualquer gráfico no nosso grafo de cena possa ter nós filhos, no SVG só <g> pode ter filhos e, fora isso, por exemplo, <circle> não pode ter filhos. A solução é simples: para elementos que não são Group e têm filhos, gere o SVG com um <g> por fora e aplique o transform que teria sido aplicado a ele. Supondo que no futuro tenhamos suporte a renderizar texto, o SVG de um Circle com filhos de texto ficaria assim:
<g transform="matrix(1,0,0,0,1,0)">
<circle cx="100" cy="100" r="100" fill="red" />
<text />
</g>Exportar inner e outerShadow
Consultando Adding Shadows to SVG Icons With CSS and SVG Filters, usamos filter primitive para realizar sombras internas e externas. Exemplo de sombras externas:
const $feDropShadow = createSVGElement('feDropShadow', doc);
$feDropShadow.setAttribute('dx', `${(dropShadowOffsetX || 0) / 2}`);
$feDropShadow.setAttribute('dy', `${(dropShadowOffsetY || 0) / 2}`);
$feDropShadow.setAttribute(
'stdDeviation',
`${(dropShadowBlurRadius || 0) / 4}`,
);
$feDropShadow.setAttribute('flood-color', dropShadowColor);
$filter.appendChild($feDropShadow);As sombras internas são um pouco mais complicadas, então não vou detalhar por falta de espaço. Você pode baixar o SVG para vê-lo no exemplo abaixo:
$icCanvas4 = call(() => {
return document.createElement('ic-canvas-lesson10');
});call(() => {
const { Canvas, Rect } = Lesson10;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas4.parentElement.style.position = 'relative';
$icCanvas4.parentElement.appendChild($stats);
$icCanvas4.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
const rect = new Rect({
x: 50,
y: 50,
fill: 'green',
cornerRadius: 50,
batchable: false,
innerShadowColor: 'black',
innerShadowOffsetX: 10,
innerShadowOffsetY: 10,
innerShadowBlurRadius: 10,
dropShadowColor: 'black',
dropShadowOffsetX: 10,
dropShadowOffsetY: 10,
dropShadowBlurRadius: 10,
});
rect.width = 400;
rect.height = 100;
canvas.appendChild(rect);
});
$icCanvas4.addEventListener('ic-frame', (e) => {
stats.update();
});
});Exportar imagem como valor de fill
Embora ainda não tenhamos coberto a implementação da imagem de preenchimento, serializar diretamente o valor Image da propriedade fill resultaria no seguinte:
<circle fill="[object ImageBitmap]" />Isso obviamente não é exibido direito. Precisamos serializar o objeto Image para uma DataURL: basta desenhar a imagem em um <canvas> e usar o método toDataURL que apresentamos na seção anterior. Consultando Convert ImageBitmap to Blob, primeiro tentei ImageBitmapRenderingContext. O ImageBitmap pode decodificar a imagem de forma assíncrona sem bloquear a thread principal, o que ajuda a melhorar o desempenho e a capacidade de resposta da aplicação.
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();
}Infelizmente, recebemos o erro a seguir. O motivo é que já criamos uma textura usando esse ImageBitmap e não podemos transferir o controle dele para o novo <canvas>.
CAUTION
O ImageBitmap de entrada foi desanexado
Então só podemos usar o método comum drawImage para desenhar a imagem no <canvas>.
Exportação de pintura ECS e geometria rough
O exportador ECS lê a primeira camada habilitada em fills e strokes. Um preenchimento image estica até os limites da forma. Uma camada pattern, em vez disso, embute a imagem decodificada nas dimensões naturais e preserva repeat, repeat-x, repeat-y ou no-repeat. O <pattern> SVG usa coordenadas locais, com a transformação do nó aplicada uma vez ao grupo envolvente. As definições de pattern têm IDs separados quando os nós precisam de extensões de repetição diferentes.
const elements = await serializeNodesToSVGElements([
{
id: 'tile',
type: 'rect',
zIndex: 0,
x: 40,
y: 30,
width: 200,
height: 100,
fills: [
{ type: 'pattern', value: imageDataURL, repetition: 'repeat-x' },
],
},
]);A decodificação de imagem é assíncrona. Aguarde serializeNodesToSVGElements, ou exportFillGradientOrPattern / exportStrokeGradientOrPattern ao usar esses utilitários de nível mais baixo diretamente. Uma falha na decodificação da imagem rejeita a exportação. Preenchimentos de gradiente, imagem e pattern em texto mantêm suas definições ao lado do elemento <text>, para que escrever o conteúdo não remova os recursos de pintura.
Formas rough exportam a geometria local como caminhos SVG. A opacidade do grupo, a opacidade da camada de preenchimento e a opacidade da camada de traço permanecem separadas, inclusive a opacidade zero. Traços hachure usam roughFillWeight (metade da largura do contorno quando omitido ou -1). Elipses derivam o centro local e os raios de width e height. Reimportar esses caminhos SVG preserva a geometria renderizada, mas não restaura os parâmetros rough editáveis. A importação de pattern não é coberta por esse suporte de exportação.
Para quem precisa de um resultado Canvas2D, API.renderToCanvas(node, { width, height }) desenha esse nó e seus filhos em um canvas. Ele oferece suporte a preenchimentos e traços sólidos em retângulos, elipses, caminhos, polilinhas e linhas, além de retângulos preenchidos com imagem. Cada forma inicia o próprio caminho; comandos de caminho SVG, inclusive arcos, e a regra de preenchimento evenodd são convertidos para Canvas2D. Posições locais, cantos arredondados e a opacidade de nó/camada são preservados. Imagens filhas terminam a decodificação antes de o próximo irmão ser desenhado, então a ordem permanece estável. O método rejeita se uma imagem não puder ser decodificada, deixando um canvas fornecido pronto para outro desenho.
Grade no SVG
Por fim, vamos ver como implementar uma grade usando SVG. Consultando How to draw grid using HTML5 and canvas or SVG, usamos o ladrilhamento de <pattern>: para a grade reta, usamos dois conjuntos, um grande e um pequeno; para a grade de pontos, colocamos um círculo em cada um dos quatro cantos do “ladrilho”:
Outra implementação interessante é pelo stroke-dasharray de <line>, que usa menos elementos SVG do que o <circle> repetido em massa; veja Dot Grid With pattern.
Em seguida, adicione um julgamento na lógica de desenhar a grade: ela permanece igual no modo que não é de captura de tela e, no modo de captura de tela, só desenha quando a opção de incluir grade está ligada:
hooks.beginFrame.tap(() => {
if (
!this.#enableCapture ||
(this.#enableCapture && this.#captureOptions.grid)
) {
this.#grid.render(this.#device, this.#renderPass, this.#uniformBuffer);
}
});Refletir parâmetros da câmera com viewBox
Podemos colocar a largura e a altura da tela no elemento <svg>, mas como os parâmetros de transformação da câmera, como a translação x/y e o zoom (ainda não oferecemos suporte à exportação da rotação da câmera), devem se refletir no elemento SVG resultante?
Aqui você pode usar o viewBox:
const { x, y, rotation, zoom } = cameras[0].read(ComputedCamera);
$namespace.setAttribute(
'viewBox',
`${x} ${y} ${width / zoom} ${height / zoom}`,
);Otimizador de SVG
Se você olhar com atenção para os produtos SVG atuais, verá que há alguns atributos redundantes, como opacity=“1”, que é um valor padrão e não precisa estar presente de forma explícita no produto. Na verdade, essa é uma das ferramentas usadas por ferramentas de otimização de SVG como o svgo:
Arquivos SVG, especialmente os exportados de editores vetoriais, em geral contêm muita informação redundante. Isso inclui metadados do editor, comentários, elementos ocultos, valores padrão ou abaixo do ideal e outras coisas que podem ser removidas ou convertidas com segurança sem afetar a renderização.
Então precisamos manter um conjunto de tabelas de mapeamento para valores padrão de atributos e, se o valor do atributo for igual ao valor padrão, não precisamos chamar setAttribute para defini-lo:
const defaultValues = {
opacity: 1,
fillOpacity: 1,
strokeOpacity: 1,
fill: 'black',
stroke: 'none',
};
Object.entries(rest).forEach(([key, value]) => {
if (`${value}` !== '' && `${defaultValues[key]}` !== `${value}`) {
element.setAttribute(camelToKebabCase(key), `${value}`);
}
});[WIP] Fonte web embutida
Ao exportar para SVG, se fontes web forem usadas, o objetivo é embuti-las no arquivo. Naturalmente, por causa das restrições de tamanho do arquivo de fonte, é preciso algum recorte sob demanda durante a execução. Abaixo está o fluxo de processamento do Excalidraw, detalhado em: Font subsetting in excalidraw
Exportar GIF
Mais adiante, na Lição 30 - Animação baseada em tempo, abordamos efeitos conduzidos por pós-processamento e por um uniform de tempo. Para exportar esse movimento, você precisa de formatos como GIF ou WebM; aqui delineamos o primeiro.
- O caminho de renderização precisa aceitar o índice do quadro atual (ou o tempo do motor), para que cada passo mostre o momento certo do efeito.
- Capturar a tela como raster, por exemplo PNG — a seção anterior já cobriu isso.
- Ler os pixels e quantizar para uma paleta (por exemplo, 256 cores por quadro). Se você puder aceitar uma pequena perda de qualidade, 128 ou 64 cores em geral reduzem bastante o arquivo; gradientes e conteúdo parecido com foto vão mostrar mais faixas ou ruído.
- Paletas por quadro: a abordagem direta é um
quantizepor quadro e umapaletteporwriteFrame, o que funciona bem para UI plana. Para laços curtos com pouca mudança entre quadros, uma única paleta global ou menos quadros-chave podem economizar mais espaço, a um custo de implementação nitidamente maior.
import { GIFEncoder, applyPalette, quantize } from 'gifenc';
const gif = GIFEncoder();
for (let i = 0; i < frameCount; i++) {
renderFrame(i); // (1) drive time / frame
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const dataUrl = el.toDataURL('image/png', pngQuality); // (2) raster snapshot
const imageData = await imageDataFromPngDataUrl(dataUrl, w, h, ctx!);
const palette = quantize(imageData.data, 256); // (3) build palette
const index = applyPalette(imageData.data, palette); // (4) map to indices
gif.writeFrame(index, w, h, { palette, delay });
}
gif.finish();
return new Blob([new Uint8Array(gif.bytes())], { type: 'image/gif' });O ECS espera um callback de quadro do navegador depois de renderizar cada quadro, antes de ler o snapshot PNG, e exige um tamanho de canvas fixo ao longo das exportações GIF e WebM. Um canvas vazio, mudança de tamanho, erro de decodificação de PNG ou exceção de desenho rejeita a exportação, em vez de devolver um arquivo vazio ou uma animação parcial. Canvas temporários são liberados no sucesso e na falha. O WebM também para o gravador e cada trilha de captura, cancela callbacks de quadro pendentes e desanexa os handlers de imagem, para que uma exportação que falhou possa ser tentada de novo. O canvas de origem de quem chamou permanece disponível.
Os timestamps do WebM ainda seguem o relógio de captura em tempo real do MediaRecorder. O FPS escolhido avança o tempo do efeito, mas não garante uma taxa de quadros ou uma duração exatas do WebM. O GIF grava atrasos por quadro, armazenados em unidades de 10 milissegundos; os navegadores também podem limitar durações curtas de exibição.
Exportar PDF
Agora que pixels e vetores estão disponíveis, se você ainda quiser exportar para PDF pode usar o jsPDF, que oferece uma API para adicionar imagens, que não vou cobrir aqui por falta de espaço.
Vamos ver outro tópico: como renderizar uma imagem na tela.
Renderizar imagem
Em WebGL / WebGPU, muitas vezes é necessário carregar imagens e usá-las como texturas. Como carregar diferentes tipos de recursos é um processo assíncrono complexo, a maioria dos motores de renderização oferece um carregador de recursos. Muitas vezes também há suporte a outros tipos de dados além dos diferentes tipos de imagem, como áudio, JSON, glTF e outros. Abaixo está um exemplo de como PIXI Assets é usado:
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);Então, como implementar um carregador de recursos?
Carregador de imagem
loaders.gl oferece uma gama de loaders para diferentes tipos de recursos, como:
- JSON, CSV, GeoJSON, GeoTiles e outros, muito usados em aplicações de visualização.
- Formatos de modelo 3D, como glTF
- Vários formatos de textura comprimida, que são carregados e analisados em WebWorker usando CompressedTextureLoader.
Esses loaders oferecem grande conveniência para o desenvolvimento de aplicações de camada superior. Podemos usar diretamente o ImageLoader, que oferece suporte a estes formatos de imagem: PNG, JPEG, GIF, WEBP, AVIF, BMP, SVG e assim por diante. core` garante a uniformidade nas chamadas de API e a extensibilidade para diferentes tipos de loaders:
import { ImageLoader } from '@loaders.gl/images';
import { load } from '@loaders.gl/core';
const image = await load(url, ImageLoader, options);Além de definir o atributo fill diretamente, você também precisa restaurar a DataURL para um objeto Image durante a desserialização:
async function deserializeNode(data: SerializedNode) {
// data:image/png:base64...
if (fill && isString(fill) && isDataUrl(fill)) {
shape.fill = (await load(fill, ImageLoader)) as ImageBitmap;
}
}Projeto da API
Voltando ao projeto da nossa API, é claro que podemos adicionar um novo gráfico para a imagem, no modelo de Circle / Ellipse / Rect, que corresponde a [<image>] no SVG:
const image = new Image({
x: 0,
y: 0,
width: 100,
height: 100,
src: 'data:image...',
});Mas, pensando bem, uma Image deveria ter todas as capacidades de um Rect, como traços, cantos arredondados, sombras e assim por diante. Curiosamente, no Figma, se escolhermos inserir uma imagem de 1920 * 1920 na tela e exportá-la como SVG, obtemos um elemento <rect> com a estrutura a seguir (alguns valores de atributo são omitidos), e o atributo fill referencia um [<pattern>], que usa indiretamente o preenchimento por ladrilhamento de imagem:
<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>Isso nos dá uma ideia de que imagens não precisam existir como formas separadas, desde que o atributo fill de outras formas ofereça suporte a mapeamento, para que Circle / Rect e outros possam usar imagens como preenchimento. Imagine que precisamos implementar um ícone circular com traço: o projeto original exigiria um gráfico Image com algo como [<clipPath>], mas agora basta preencher o gráfico Circle existente com uma imagem:
circle.fill = image;
circle.stroke = 'black';Implementação
Então, como primeiro passo, estendemos os tipos aceitos por fill, de strings de cor para mais fontes de textura:
export interface IRenderable {
fill: string;
fill: string | TexImageSource;
}
type TexImageSource =
| ImageBitmap
| ImageData
| HTMLImageElement
| HTMLCanvasElement
| HTMLVideoElement
| OffscreenCanvas
| VideoFrame;É preciso um campo nos dados de vértice para indicar se uma textura é usada e, se for, amostrar a textura. SAMPLER_2D() não é a sintaxe GLSL padrão, mas a nossa marcação personalizada para substituí-la pela sintaxe de amostragem GLSL100 / GLSL300 / WGSL na etapa de compilação do Shader. Além disso, as texturas atualmente são imagens enviadas, e gradientes criados com a API Canvas2D, como [createLinearGradient], serão suportados depois:
// 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);
}O uso de uniform quebra a nossa lógica anterior de renderização em lote. O artigo [Inside PixiJS: Batch Rendering System] descreve a lógica da implementação de BatchRenderer do Pixi.js e, como você pode ver no template de Shader compilado em tempo de execução abaixo, o número máximo de samplers que podem ser suportados ao mesmo tempo é um conjunto de %count% samplers, em que cada instância seleciona dentro do conjunto de samplers usando o dado de vértice aTextureId, e aTextureId é usado por cada instância para selecionar dentro do conjunto de samplers.
// Shader template in Pixi.js BatchRenderer
// vert
attribute int aTextureId;
varying int vTextureId;
vTextureId = aTextureId;
// frag
uniform sampler2D uSamplers[%count%];
varying int vTextureId;Para simplificar, vamos simplificar a lógica de mesclagem da seguinte forma:
fillcom uma string de cor não pode ser combinado com uma Image.fillcom um valor de Image diferente não pode ser combinado.
Então os três círculos a seguir serão desenhados no mesmo lote.
$icCanvas3 = call(() => {
return document.createElement('ic-canvas-lesson10');
});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('/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();
});
});Cache de renderização
Antes, não consideramos o cache de objetos de GPU como Program, Bindings, Sampler e outros. Por isso, adicionamos um gerenciador de cache de recursos para permitir a reutilização e implementamos a lógica de acerto de acordo com o tipo de recurso. Tome o Sampler como exemplo: se as propriedades em SamplerDescriptor forem idênticas, o cache é atingido, e a lógica de comparação está em samplerDescriptorEquals.
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;
}
}Object fit (object-fit)
A geometria de um retângulo muitas vezes não corresponde à proporção intrínseca da imagem. Desenhar com drawImage(0, 0, width, height) em todo lugar estica ícones e fotos. Ferramentas de design em geral oferecem modos de fit / fill / crop; seguimos o CSS [object-fit] e [object-position] em entradas de FillLayers com type: 'image':
fills: [
{
type: 'image',
value: '/canvas.png',
objectFit: 'contain',
objectPosition: 'top left',
},
];objectFit | Comportamento |
|---|---|
fill | Estica para preencher a geometria (padrão, igual ao comportamento raster inicial) |
contain | Escala de forma uniforme; mostra a imagem inteira; pode aparecer letterboxing |
cover | Escala de forma uniforme; preenche a geometria; recorta o que transborda |
none / scale-down | A mesma semântica do CSS |
Veja Exemplo de object-fit para saber como isso se mapeia à escala de imagem do Figma e ao mode do Pencil (stretch / fill / fit).
Na CPU, rasterizamos a imagem em uma textura: calculamos o retângulo de desenho a partir de objectFit e pintamos em um canvas. Para contain / cover, o tamanho da textura é geometria × device pixel ratio, não max(source width, geometry width) — caso contrário, a imagem inteira preenche o bitmap e, ao ser mapeada para a forma, ainda parece um fill esticado.
iOS Live Photo
No nível do arquivo, um iOS Live Photo em geral é uma imagem estática HEIC mais um MOV acompanhante. O suporte nativo a HEIC varia entre navegadores; o carregador de imagem do ECS usa heic-to para oferecer um caminho de conversão.
O objetivo é tratá-lo como uma imagem normal: decodificar um único quadro estático em um bitmap e, então, enviá-lo para uma textura. Reproduzir o vídeo embutido do Live Photo na tela seria um passo seguinte, com um componente dedicado (por exemplo, toque para reproduzir) construído à parte.
loadImageBitmapUniversal aceita uma URL, um Blob ou um File. Ele reconhece HEIC/HEIF pelo MIME, pela extensão do arquivo ou pela assinatura do arquivo, e carrega heic-to apenas quando necessário, mantendo as importações de módulo seguras durante o SSR. Ele converte o primeiro quadro estático para PNG, com uma nova tentativa em JPEG se a conversão ou a decodificação do bitmap falhar. Imagens que não são HEIC continuam pelo loaders.gl.
Erros de HTTP são rejeitados antes da conversão. Se uma imagem comum falhar ao decodificar e a sondagem diagnóstica de HEIC também falhar, o erro original é preservado. Cada chamada pode tentar de novo de forma independente; um bitmap retornado pertence a quem chamou e deve ser fechado depois do uso. Uma object URL de entrada também permanece sob a posse de quem a chamou.
import { loadImageBitmapUniversal } from '@infinite-canvas-tutorial/ecs';
async function drawStill(
context: CanvasRenderingContext2D,
source: string | Blob,
) {
const bitmap = await loadImageBitmapUniversal(source);
try {
context.drawImage(bitmap, 0, 0);
} finally {
bitmap.close?.();
}
}O decodificador usa heic-to 1.6.6 (libheif 1.23.6), corrigindo o desvio de cor e a transparência perdida do decodificador anterior nos fixtures SDR de faixa completa. Regressões de navegador comparam com as cores de origem e cobrem níveis de cinza, transparência de PNG, o primeiro quadro estático em um contêiner de várias imagens, decodificação concorrente e recuperação depois de entrada corrompida. O fallback JPEG continua com perdas e não consegue preservar o alfa. Essas verificações não cobrem perfis ICC arbitrários, cor de gama ampla, HDR ou a reprodução de vídeo do Live Photo.
SVG aprimorado: alinhamento do traço
Por fim, vamos apresentar um tópico interessante. Podemos implementar recursos que ainda não são suportados pela especificação SVG.
Vamos começar pela diferença entre opacity, stroke-opacity e fill-opacity no SVG. O círculo da esquerda tem opacity=“0.5” e o círculo da direita tem fill-opacity=“0.5” stroke-opacity=“0.5”. Você pode ver que o traço fica metade dentro do círculo e metade fora:
A posição de Stroke correspondente no Figma é Center, e outras opções incluem Inside e Outside, cada uma mostrada abaixo. No SVG, isso se chama stroke-alignment, mas ainda está em forma de rascunho; veja Specifying stroke alignment. O Pixi.js também oferece [PIXI.LineStyle alignment] no modo WebGL.

Adicionamos a propriedade strokeAlignment a todas as formas:
export interface IRenderable {
strokeAlignment: 'center' | 'inner' | 'outer';
}Alinhamento do traço no Shader
A implementação na seção do Shader só precisa distinguir esses três valores e misturar as cores de preenchimento e de traço de formas diferentes:
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
}Eis a nossa implementação. Você pode ver que a renderização é consistente com o Figma:
$icCanvas2 = call(() => {
return document.createElement('ic-canvas-lesson10');
});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();
});
});Essa propriedade também precisa ser levada em conta ao calcular a caixa delimitadora de renderização e a determinação de picking. A função abaixo reflete o quanto o traço deve se estender para fora do próprio gráfico para valores diferentes.
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;
}
}Exportar SVG
Como mencionado antes, o SVG ainda não oferece suporte a stroke-alignment, então, no momento, só pode ser simulado com uma gambiarra. Se o desenho for simples, é possível desenhar o preenchimento e o traço em duas passadas. Eis a exportação do Figma de stroke-alignment: 'inner', que também usa essa abordagem:
<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"
/>Além disso, a documentação do Figma em StrokeAlign in Figma widget apresenta outra ideia, que não exige criar dois elementos parecidos. How to simulate stroke-align (stroke-alignment) in SVG tenta essa ideia ampliando a largura do traço para o dobro da largura original e eliminando o excesso com clipPath e mask:
Traço interno e externo são, na verdade, implementados dobrando a espessura do traço e mascarando o traço pelo preenchimento. Isso significa que o traço alinhado por dentro nunca desenha traços fora do preenchimento e o traço alinhado por fora nunca desenha traços dentro do preenchimento.
Para simplificar a implementação, escolhemos a primeira abordagem, criando dois elementos parecidos para desenhar o preenchimento e o traço respectivamente, o que pode ser experimentado ao exportar SVG no exemplo acima.
Com a riqueza da funcionalidade da tela, é necessário introduzir testes para verificar se a funcionalidade de importação/exportação funciona direito, se a renderização está correta e se os componentes de UI são interativos. Vamos apresentar isso na próxima seção.