Às vezes, você quer usar uma biblioteca que só está disponível como código C ou C++. Tradicionalmente, é aqui que você desiste. Mas isso mudou. Agora temos o Emscripten e o WebAssembly (ou Wasm)!
O conjunto de ferramentas
Meu objetivo era descobrir como compilar um código C para Wasm. Houve algum ruído em torno do back-end Wasm do LLVM, então comecei a investigar isso. Embora seja possível compilar programas simples dessa forma, assim que você quiser usar a biblioteca padrão do C ou até mesmo compilar vários arquivos, provavelmente vai ter problemas. Isso me levou à principal lição que aprendi:
Embora o Emscripten tenha sido um compilador de C para asm.js, ele evoluiu para Wasm e está em processo de troca para o back-end oficial do LLVM internamente. O Emscripten também oferece uma implementação compatível com Wasm da biblioteca padrão do C. Use o Emscripten. Ele realiza muito trabalho oculto, emula um sistema de arquivos, oferece gerenciamento de memória, envolve o OpenGL com o WebGL. São muitas coisas que você não precisa experimentar ao desenvolver por conta própria.
Embora isso possa parecer que você precisa se preocupar com o aumento do tamanho do código (eu certamente me preocupei), o compilador Emscripten remove tudo o que não é necessário. Nos meus experimentos, os módulos Wasm resultantes têm o tamanho adequado para a lógica que eles contêm, e as equipes do Emscripten e do WebAssembly estão trabalhando para torná-los ainda menores no futuro.
Para instalar o Emscripten, siga as instruções no site ou use o Homebrew. Se você gosta de comandos dockerizados como eu e não quer instalar nada no seu sistema apenas para testar o WebAssembly, há uma imagem do Docker bem mantida que você pode usar em vez disso:
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
Compilar algo simples
Vamos usar o exemplo quase canônico de escrever uma função em C que calcula o nésimo número de Fibonacci:
#include <emscripten.h>
EMSCRIPTEN_KEEPALIVE
int fib(int n) {
if(n <= 0){
return 0;
}
int i, t, a = 0, b = 1;
for (i = 1; i < n; i++) {
t = a + b;
a = b;
b = t;
}
return b;
}
Se você conhece C, a função não deve ser muito surpreendente. Mesmo que você não conheça C, mas saiba JavaScript, esperamos que você consiga entender o que está acontecendo aqui.
emscripten.h
é um arquivo de cabeçalho fornecido pelo Emscripten. Só precisamos dele para ter acesso à macro EMSCRIPTEN_KEEPALIVE
, mas ele oferece muito mais funcionalidades.
Essa macro informa ao compilador para não remover uma função, mesmo que ela pareça
não utilizada. Se omitíssemos essa macro, o compilador otimizaria a função, já que ninguém a está usando.
Vamos salvar tudo isso em um arquivo chamado fib.c
. Para transformá-lo em um arquivo .wasm
, precisamos usar o comando do compilador emcc
do Emscripten:
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
Vamos analisar esse comando. emcc
é o compilador do Emscripten. fib.c
é nosso arquivo C. Até aqui, tudo bem. -s WASM=1
diz ao Emscripten para fornecer um arquivo Wasm
em vez de um arquivo asm.js.
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]'
informa ao compilador para deixar a função
cwrap()
disponível no arquivo JavaScript. Falaremos mais sobre essa função
adiante. -O3
informa ao compilador para otimizar de forma agressiva. Você pode escolher números menores para diminuir o tempo de build, mas isso também vai aumentar os pacotes resultantes, já que o compilador pode não remover o código não utilizado.
Depois de executar o comando, você terá um arquivo JavaScript chamado
a.out.js
e um arquivo WebAssembly chamado a.out.wasm
. O arquivo Wasm (ou "módulo") contém nosso código C compilado e deve ser relativamente pequeno. O arquivo JavaScript carrega e inicializa nosso módulo Wasm e fornece uma API mais agradável. Se necessário, ele também vai configurar a pilha, o heap e outras funcionalidades que geralmente são fornecidas pelo sistema operacional ao escrever código C. Por isso, o arquivo JavaScript é um pouco maior, pesando 19 KB (~5 KB compactado com gzip).
Executar algo simples
A maneira mais fácil de carregar e executar seu módulo é usar o arquivo JavaScript
gerado. Depois de carregar esse arquivo, você terá um
Module
global
à sua disposição. Use
cwrap
para criar uma função nativa do JavaScript que converte parâmetros
em algo compatível com C e invoca a função encapsulada. cwrap
usa o nome da função, o tipo de retorno e os tipos de argumento como argumentos, nessa ordem:
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
Se você executar esse código, verá "144" no console, que é o 12º número de Fibonacci.
O Santo Graal: como compilar uma biblioteca C
Até agora, o código C que escrevemos foi criado pensando no Wasm. No entanto, um caso de uso principal do WebAssembly é aproveitar o ecossistema atual de bibliotecas C e permitir que os desenvolvedores as usem na Web. Essas bibliotecas geralmente dependem da biblioteca padrão do C, de um sistema operacional, de um sistema de arquivos e de outras coisas. O Emscripten oferece a maioria desses recursos, mas há algumas limitações.
Vamos voltar à minha meta original: compilar um codificador para WebP em Wasm. A fonte do codec WebP está escrita em C e disponível no GitHub, além de uma extensa documentação da API. Esse é um bom ponto de partida.
$ git clone https://github.com/webmproject/libwebp
Para começar de forma simples, vamos tentar expor WebPGetEncoderVersion()
de
encode.h
para JavaScript escrevendo um arquivo C chamado webp.c
:
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
Este é um bom programa simples para testar se podemos compilar o código-fonte do libwebp, já que não precisamos de parâmetros ou estruturas de dados complexas para invocar essa função.
Para compilar esse programa, precisamos informar ao compilador onde ele pode encontrar os arquivos de cabeçalho do libwebp usando a flag -I
e também transmitir todos os arquivos C do libwebp necessários. Vou ser honesto: eu apenas dei todos os arquivos C que consegui encontrar e confiei no compilador para remover tudo o que era desnecessário. Parece que funcionou muito bem!
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
Agora só precisamos de um pouco de HTML e JavaScript para carregar nosso novo módulo brilhante:
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
E vamos ver o número da versão da correção na saída:
Extrair uma imagem do JavaScript para o Wasm
Receber o número da versão do codificador é ótimo, mas codificar uma imagem real seria mais impressionante, certo? Então vamos fazer isso.
A primeira pergunta que precisamos responder é: como colocamos a imagem no mundo do Wasm?
A API de codificação da libwebp espera uma matriz de bytes em RGB, RGBA, BGR ou BGRA. Felizmente, a API Canvas tem
getImageData()
,
que nos dá um
Uint8ClampedArray
com os dados da imagem em RGBA:
async function loadImage(src) {
// Load image
const imgBlob = await fetch(src).then((resp) => resp.blob());
const img = await createImageBitmap(imgBlob);
// Make canvas same size as image
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
// Draw image onto canvas
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return ctx.getImageData(0, 0, img.width, img.height);
}
Agora, é "apenas" uma questão de copiar os dados do JavaScript para o Wasm. Para isso, precisamos expor mais duas funções. Um que aloca memória para a imagem no Wasm e outro que a libera novamente:
EMSCRIPTEN_KEEPALIVE
uint8_t* create_buffer(int width, int height) {
return malloc(width * height * 4 * sizeof(uint8_t));
}
EMSCRIPTEN_KEEPALIVE
void destroy_buffer(uint8_t* p) {
free(p);
}
create_buffer
aloca um buffer para a imagem RGBA, ou seja, 4 bytes por pixel.
O ponteiro retornado por malloc()
é o endereço da primeira célula de memória desse buffer. Quando o ponteiro é retornado ao JavaScript, ele é tratado como
apenas um número. Depois de expor a função ao JavaScript usando cwrap
, podemos
usar esse número para encontrar o início do nosso buffer e copiar os dados da imagem.
const api = {
version: Module.cwrap('version', 'number', []),
create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);
Grande final: codifique a imagem
A imagem agora está disponível no Wasm. É hora de chamar o codificador WebP para
fazer o trabalho dele. Analisando a documentação do WebP, WebPEncodeRGBA
parece ser uma opção perfeita. A função usa um ponteiro para a imagem de entrada e as dimensões dela, além de uma opção de qualidade entre 0 e 100. Ele também aloca um buffer de saída para nós, que precisamos liberar usando WebPFree()
depois de terminar de usar a imagem WebP.
O resultado da operação de codificação é um buffer de saída e o comprimento dele. Como as funções em C não podem ter matrizes como tipos de retorno (a menos que aloquemos memória dinamicamente), recorri a uma matriz global estática. Eu sei, não é C limpo (na verdade, ele depende do fato de que os ponteiros Wasm têm 32 bits de largura), mas para simplificar, acho que esse é um atalho justo.
int result[2];
EMSCRIPTEN_KEEPALIVE
void encode(uint8_t* img_in, int width, int height, float quality) {
uint8_t* img_out;
size_t size;
size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);
result[0] = (int)img_out;
result[1] = size;
}
EMSCRIPTEN_KEEPALIVE
void free_result(uint8_t* result) {
WebPFree(result);
}
EMSCRIPTEN_KEEPALIVE
int get_result_pointer() {
return result[0];
}
EMSCRIPTEN_KEEPALIVE
int get_result_size() {
return result[1];
}
Agora, com tudo isso definido, podemos chamar a função de codificação, pegar o ponteiro e o tamanho da imagem, colocar em um buffer JavaScript próprio e liberar todos os buffers Wasm alocados no processo.
api.encode(p, image.width, image.height, 100);
const resultPointer = api.get_result_pointer();
const resultSize = api.get_result_size();
const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
const result = new Uint8Array(resultView);
api.free_result(resultPointer);
Dependendo do tamanho da imagem, você pode encontrar um erro em que o Wasm não consegue aumentar a memória o suficiente para acomodar a imagem de entrada e a de saída:
Felizmente, a solução para esse problema está na mensagem de erro. Basta adicionar -s ALLOW_MEMORY_GROWTH=1
ao comando de compilação.
Pronto! Compilamos um codificador WebP e transcodificamos uma imagem JPEG para WebP. Para provar que funcionou, podemos transformar nosso buffer de resultado em um blob e usá-lo em um elemento <img>
:
const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);
Contemple a glória de uma nova imagem WebP!
Conclusão
Não é fácil fazer uma biblioteca C funcionar no navegador, mas depois de entender o processo geral e como o fluxo de dados funciona, fica mais fácil, e os resultados podem ser incríveis.
O WebAssembly abre muitas novas possibilidades na Web para processamento, cálculos numéricos e jogos. O Wasm não é uma solução mágica que deve ser aplicada a tudo, mas quando você encontra um desses gargalos, ele pode ser uma ferramenta incrivelmente útil.
Conteúdo bônus: como fazer algo simples de um jeito difícil
Se você quiser tentar evitar o arquivo JavaScript gerado, talvez seja possível. Vamos voltar ao exemplo de Fibonacci. Para carregar e executar por conta própria, podemos fazer o seguinte:
<!DOCTYPE html>
<script>
(async function () {
const imports = {
env: {
memory: new WebAssembly.Memory({ initial: 1 }),
STACKTOP: 0,
},
};
const { instance } = await WebAssembly.instantiateStreaming(
fetch('/a.out.wasm'),
imports,
);
console.log(instance.exports._fib(12));
})();
</script>
Os módulos WebAssembly criados pelo Emscripten não têm memória para trabalhar, a menos que você forneça. Para fornecer um módulo Wasm com qualquer coisa, use o objeto imports
, o segundo parâmetro da função instantiateStreaming
. O módulo Wasm pode acessar tudo dentro do objeto de importações, mas nada fora dele. Por convenção, os módulos compilados pelo Emscripten esperam algumas coisas do ambiente JavaScript de carregamento:
- Primeiro, há o
env.memory
. O módulo Wasm não tem conhecimento do mundo externo, por assim dizer, então ele precisa de alguma memória para trabalhar. InsiraWebAssembly.Memory
. Ele representa uma parte (opcionalmente expansível) da memória linear. Os parâmetros de dimensionamento estão em "unidades de páginas WebAssembly", ou seja, o código acima aloca uma página de memória, com cada página tendo um tamanho de 64 KiB. Sem fornecer uma opçãomaximum
, a memória é teoricamente ilimitada em crescimento (o Chrome atualmente tem um limite rígido de 2 GB). A maioria dos módulos WebAssembly não precisa definir um máximo. env.STACKTOP
define onde a pilha deve começar a crescer. A pilha é necessária para fazer chamadas de função e alocar memória para variáveis locais. Como não fazemos nenhuma manipulação dinâmica de gerenciamento de memória no nosso pequeno programa de Fibonacci, podemos usar toda a memória como uma pilha, portantoSTACKTOP = 0
.