Co to jest WebAssembly i skąd się wzięło? Wyjaśniłem, jak powstała obecna wersja WebAssembly. W tym artykule pokażę, jak skompilować istniejący program C, mkbitmap
, do formatu WebAssembly. Jest ono bardziej skomplikowane niż przykład hello world, ponieważ obejmuje pracę z plikami, komunikację między WebAssembly a JavaScriptem oraz rysowanie na płótnie, ale nadal jest na tyle przystępne, że nie przytłoczy Cię.
Ten artykuł jest przeznaczony dla programistów stron internetowych, którzy chcą nauczyć się WebAssembly. Zawiera on szczegółowe instrukcje dotyczące kompilowania kodu, np. mkbitmap
, do WebAssembly. Uprzedzam, że niekompilowanie się aplikacji lub biblioteki przy pierwszym uruchomieniu jest całkowicie normalne, dlatego niektóre z opisanych poniżej kroków nie zadziałały. Musiałem wrócić do poprzedniego stanu i spróbować jeszcze raz. W artykule nie znajdziesz magicznego polecenia kompilacji, które spadnie na Ciebie z nieba, ale opisuję w nim moje postępy, w tym pewne rozczarowania.
mkbitmap
– informacje
Program w języku C mkbitmap
odczytuje obraz i zastosuje do niego co najmniej jedną z tych operacji w podanej kolejności: odwrócenie, filtrowanie pasmowego, skalowanie i ustawienie progu. Każdą operację można włączać i wyłączać osobno. Głównym zastosowaniem polecenia mkbitmap
jest konwertowanie obrazów kolorowych lub w szarościach na format odpowiedni do użycia w innych programach, zwłaszcza w programie do tworzenia ścieżek potrace
, który stanowi podstawę SVGcode. Jako narzędzie do wstępnej obróbki mkbitmap
jest szczególnie przydatne do konwertowania zeskanowanych grafik wektorowych, takich jak kreskówki czy tekst odręczny, na dwupoziomowe obrazy o wysokiej rozdzielczości.
Funkcji mkbitmap
używa się, przekazując jej pewną liczbę opcji i jedną lub więcej nazw plików. Szczegółowe informacje znajdziesz na stronie man tego narzędzia:
$ mkbitmap [options] [filename...]
Pobierz kod
Pierwszym krokiem jest uzyskanie kodu źródłowego mkbitmap
. Znajdziesz go na stronie projektu. W chwili pisania tego tekstu najnowszą wersją jest potrace-1.16.tar.gz.
Kompilowanie i instalowanie lokalnie
Kolejnym krokiem jest skompilowanie i zainstalowanie narzędzia lokalnie, aby sprawdzić, jak się zachowuje. Plik INSTALL
zawiera te instrukcje:
cd
do katalogu zawierającego kod źródłowy pakietu i wpisz./configure
, aby skonfigurować pakiet dla swojego systemu.Uruchamianie
configure
może trochę potrwać. Podczas działania wyświetla komunikaty z informacją, które funkcje są sprawdzane.Aby skompilować pakiet, wpisz
make
.Opcjonalnie wpisz
make check
, aby uruchomić autotesty dołączone do pakietu, zazwyczaj przy użyciu właśnie skompilowanych, nieinstalowanych plików binarnych.Wpisz
make install
, aby zainstalować programy oraz pliki danych i dokumentację. Podczas instalowania w prefiksie należącym do roota zalecamy skonfigurowanie i skompilowanie pakietu jako zwykły użytkownik, a tylko fazęmake install
należy wykonać z uprawnieniami roota.
Po wykonaniu tych czynności powinny pojawić się 2 pliki wykonywalne: potrace
i mkbitmap
. Ten drugi jest tematem tego artykułu. Aby sprawdzić, czy wszystko działa prawidłowo, uruchom polecenie mkbitmap --version
. Oto wynik wszystkich 4 etapów, które wykonał mój komputer. Z powodów zwiężenia tekstu pominięto wiele szczegółów:
Krok 1. ./configure
:
$ ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... ./install-sh -c -d
checking for gawk... no
checking for mawk... no
checking for nawk... no
checking for awk... awk
checking whether make sets $(MAKE)... yes
[…]
config.status: executing libtool commands
Krok 2. make
:
$ make
/Applications/Xcode.app/Contents/Developer/usr/bin/make all-recursive
Making all in src
clang -DHAVE_CONFIG_H -I. -I.. -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
[…]
make[2]: Nothing to be done for `all-am'.
Krok 3. make check
:
$ make check
Making check in src
make[1]: Nothing to be done for `check'.
Making check in doc
make[1]: Nothing to be done for `check'.
[…]
============================================================================
Testsuite summary for potrace 1.16
============================================================================
# TOTAL: 8
# PASS: 8
# SKIP: 0
# XFAIL: 0
# FAIL: 0
# XPASS: 0
# ERROR: 0
============================================================================
make[1]: Nothing to be done for `check-am'.
Krok 4. sudo make install
:
$ sudo make install
Password:
Making install in src
.././install-sh -c -d '/usr/local/bin'
/bin/sh ../libtool --mode=install /usr/bin/install -c potrace mkbitmap '/usr/local/bin'
[…]
make[2]: Nothing to be done for `install-data-am'.
Aby sprawdzić, czy wszystko działa prawidłowo, uruchom mkbitmap --version
:
$ mkbitmap --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.
Jeśli zobaczysz szczegóły wersji, oznacza to, że udało Ci się skompilować i zainstalować mkbitmap
. Następnie wykonaj te same czynności w przypadku WebAssembly.
Kompilowanie mkbitmap
w standardzie WebAssembly
Emscripten to narzędzie do kompilowania programów C/C++ do WebAssembly. W dokumentacji Tworzenie projektów Emscripten można przeczytać:
Budowanie dużych projektów za pomocą Emscripten jest bardzo proste. Emscripten udostępnia 2 proste skrypty, które konfigurują pliki make, aby używać
emcc
jako zamiennika dlagcc
. W większości przypadków pozostała część obecnego systemu kompilacji projektu pozostaje niezmieniona.
Dokumentacja zawiera też (w uproszczeniu) następujące informacje:
Załóżmy, że zwykle kompilujesz za pomocą tych poleceń:
./configure
make
Aby skompilować za pomocą Emscripten, użyj zamiast tego tych poleceń:
emconfigure ./configure
emmake make
Oznacza to, że ./configure
staje się emconfigure ./configure
, a make
staje się emmake make
. Poniżej pokazujemy, jak to zrobić w przypadku mkbitmap
.
Krok 0, make clean
:
$ make clean
Making clean in src
rm -f potrace mkbitmap
test -z "" || rm -f
rm -rf .libs _libs
[…]
rm -f *.lo
Krok 1. emconfigure ./configure
:
$ emconfigure ./configure
configure: ./configure
checking for a BSD-compatible install... /usr/bin/install -c
checking whether build environment is sane... yes
checking for a thread-safe mkdir -p... ./install-sh -c -d
checking for gawk... no
checking for mawk... no
checking for nawk... no
checking for awk... awk
[…]
config.status: executing libtool commands
Krok 2. emmake make
:
$ emmake make
make: make
/Applications/Xcode.app/Contents/Developer/usr/bin/make all-recursive
Making all in src
/opt/homebrew/Cellar/emscripten/3.1.36/libexec/emcc -DHAVE_CONFIG_H -I. -I.. -g -O2 -MT main.o -MD -MP -MF .deps/main.Tpo -c -o main.o main.c
mv -f .deps/main.Tpo .deps/main.Po
[…]
make[2]: Nothing to be done for `all'.
Jeśli wszystko poszło dobrze, w katalogu powinny się teraz znajdować pliki .wasm
. Aby je znaleźć, uruchom find . -name "*.wasm"
:
$ find . -name "*.wasm"
./a.wasm
./src/mkbitmap.wasm
./src/potrace.wasm
Ostatnie 2 wyglądają obiecująco, więc cd
do katalogu src/
. Dostępne są też 2 nowe pliki: mkbitmap
i potrace
. W tym artykule istotna jest tylko wartość mkbitmap
. Fakt, że nie mają rozszerzenia .js
, może być nieco mylący, ale są to w istocie pliki JavaScript, co można sprawdzić, wykonując szybkie wywołanie head
:
$ cd src/
$ head -n 20 mkbitmap
// include: shell.js
// The Module object: Our interface to the outside world. We import
// and export values on it. There are various ways Module can be used:
// 1. Not defined. We create it here
// 2. A function parameter, function(Module) { ..generated code.. }
// 3. pre-run appended it, var Module = {}; ..generated code..
// 4. External script tag defines var Module.
// We need to check if Module already exists (e.g. case 3 above).
// Substitution will be replaced with actual code on later stage of the build,
// this way Closure Compiler will not mangle it (e.g. case 4. above).
// Note that if you want to run closure, and also to use Module
// after the generated code, you will need to define var Module = {};
// before the code. Then that object will be used in the code, and you
// can continue to use Module afterwards as well.
var Module = typeof Module != 'undefined' ? Module : {};
// --pre-jses are emitted after the Module integration code, so that they can
// refer to Module (if they choose; they can also define Module)
Zmień nazwę pliku JavaScript na mkbitmap.js
, wywołując funkcję mv mkbitmap mkbitmap.js
(i odpowiednio mv potrace potrace.js
).
Czas na pierwszy test, aby sprawdzić, czy wszystko działa prawidłowo. Uruchom plik za pomocą Node.js w wierszu poleceń, wykonując polecenie node mkbitmap.js --version
:
$ node mkbitmap.js --version
mkbitmap 1.16. Copyright (C) 2001-2019 Peter Selinger.
Kompilacja mkbitmap
została zakończona i plik został zapisany w formacie WebAssembly. Następnym krokiem jest uruchomienie aplikacji w przeglądarce.
mkbitmap
w standardzie WebAssembly w przeglądarce
Skopiuj pliki mkbitmap.js
i mkbitmap.wasm
do nowego katalogu o nazwie mkbitmap
i utwórz szablon HTML index.html
, który wczytuje plik JavaScript mkbitmap.js
.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>mkbitmap</title>
</head>
<body>
<script src="mkbitmap.js"></script>
</body>
</html>
Uruchom serwer lokalny, który obsługuje katalog mkbitmap
, i otwórz go w przeglądarce. Powinien pojawić się komunikat z prośbą o podanie danych. To się zgadza, ponieważ według strony man narzędzia [i]f no filename arguments are given, then mkbitmap acts as a filter, reading from standard input", co w przypadku Emscripten domyślnie oznacza prompt()
.
Zapobieganie automatycznemu wykonywaniu
Aby zapobiec natychmiastowemu wykonywaniu funkcji mkbitmap
i zamiast tego spowodować, że będzie ona oczekiwać na dane wejściowe użytkownika, musisz zrozumieć obiekt Module
w Emscripten. Module
to globalny obiekt JavaScriptu z atrybutami, które kod wygenerowany przez Emscripten wywołuje w różnych punktach swojego wykonania.
Aby kontrolować wykonywanie kodu, możesz podać implementację Module
.
Gdy aplikacja Emscripten uruchamia się, sprawdza wartości obiektu Module
i je stosuje.
W przypadku mkbitmap
ustaw wartość Module.noInitialRun
na true
, aby zapobiec początkowemu uruchomieniu, które spowodowało wyświetlenie promptu. Utwórz skrypt o nazwie script.js
, umieść go przed tagiem <script src="mkbitmap.js"></script>
w pliku index.html
i dodaj do niego ten kod:script.js
Gdy ponownie wczytasz aplikację, ten komunikat powinien zniknąć.
var Module = {
// Don't run main() at page load
noInitialRun: true,
};
Tworzenie modułowej wersji z dodatkowymi flagami kompilacji
Aby podać dane wejściowe aplikacji, możesz użyć obsługi systemu plików w Emscripten w Module.FS
. W sekcji Obsługa systemu plików w dokumentacji można przeczytać:
Emscripten automatycznie decyduje, czy uwzględnić obsługę systemu plików. Wiele programów nie wymaga plików, a obsługa systemu plików nie jest nieznacząca, więc Emscripten nie uwzględnia jej, jeśli nie ma ku temu powodu. Oznacza to, że jeśli kod C/C++ nie ma dostępu do plików, obiekt
FS
i inne interfejsy API systemu plików nie będą uwzględniane w wyjściu. Jeśli jednak Twój kod C/C++ używa plików, obsługa systemu plików zostanie automatycznie uwzględniona.
Niestety mkbitmap
to jeden z przypadków, w których Emscripten nie obsługuje automatycznie systemu plików, więc musisz wyraźnie go o to poprosić. Oznacza to, że musisz wykonać czynności opisane wcześniej w sekcji emconfigure
i emmake
, ale z kilkoma dodatkowymi flagami ustawionymi za pomocą argumentu CFLAGS
. Te flagi mogą być przydatne również w innych projektach.
- Ustaw
-sFILESYSTEM=1
, aby uwzględnić obsługę systemu plików. - Ustaw
-sEXPORTED_RUNTIME_METHODS=FS,callMain
, aby eksportować daneModule.FS
iModule.callMain
. - Aby wygenerować nowoczesny moduł ES6, ustaw opcje
-sMODULARIZE=1
i-sEXPORT_ES6
. - Ustaw wartość
-sINVOKE_RUN=0
, aby zapobiec początkowemu uruchomieniu, które spowodowało wyświetlenie promptu.
W tym konkretnym przypadku musisz też ustawić flagę --host
na wasm32
, aby poinformować skrypt configure
, że kompilujesz go dla WebAssembly.
Ostateczne polecenie emconfigure
wygląda tak:
$ emconfigure ./configure --host=wasm32 CFLAGS='-sFILESYSTEM=1 -sEXPORTED_RUNTIME_METHODS=FS,callMain -sMODULARIZE=1 -sEXPORT_ES6 -sINVOKE_RUN=0'
Nie zapomnij ponownie uruchomić polecenia emmake make
i skopiować utworzone właśnie pliki do folderu mkbitmap
.
Zmodyfikuj plik index.html
, tak aby wczytywał tylko moduł ES script.js
, z którego zaimportujesz moduł mkbitmap.js
.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>mkbitmap</title>
</head>
<body>
<!-- No longer load `mkbitmap.js` here -->
<script src="script.js" type="module"></script>
</body>
</html>
// This is `script.js`.
import loadWASM from './mkbitmap.js';
const run = async () => {
const Module = await loadWASM();
console.log(Module);
};
run();
Gdy otworzysz aplikację w przeglądarce, obiekt Module
powinien zostać zarejestrowany w konsoli DevTools, a prompt zniknie, ponieważ funkcja main()
obiektu mkbitmap
nie jest już wywoływana na początku.
Ręczne wykonywanie głównej funkcji
Następnym krokiem jest ręczne wywołanie funkcji main()
klasy mkbitmap
przez uruchomienie funkcji Module.callMain()
. Funkcja callMain()
przyjmuje tablicę argumentów, które są pojedynczo zgodne z tym, co przekazujesz na linii poleceń. Jeśli w wierszu poleceń uruchomisz mkbitmap -v
, w przeglądarce uruchomisz Module.callMain(['-v'])
. Spowoduje to zapisanie numeru wersji mkbitmap
w konsoli Narzędzi deweloperskich.
// This is `script.js`.
import loadWASM from './mkbitmap.js';
const run = async () => {
const Module = await loadWASM();
Module.callMain(['-v']);
};
run();
Przekierowywanie standardowego wyjścia
Standardowe wyjście (stdout
) to domyślnie konsola. Możesz jednak przekierować go na coś innego, np. na funkcję, która przechowuje dane wyjściowe w zmiennej. Oznacza to, że możesz dodać dane wyjściowe do kodu HTML, ustawiając właściwość Module.print
.
// This is `script.js`.
import loadWASM from './mkbitmap.js';
const run = async () => {
let consoleOutput = 'Powered by ';
const Module = await loadWASM({
print: (text) => (consoleOutput += text),
});
Module.callMain(['-v']);
document.body.textContent = consoleOutput;
};
run();
Przesyłanie pliku wejściowego do systemu plików w pamięci
Aby w pamięci utworzyć system plików, musisz użyć odpowiednika polecenia mkbitmap filename
w wierszu poleceń. Aby zrozumieć, jak to robię, najpierw wyjaśnię, jak mkbitmap
otrzymuje dane wejściowe i tworzy dane wyjściowe.
Obsługiwane formaty wejściowe mkbitmap
to PNM (PBM, PGM, PPM) i BMP. Formaty wyjściowe to PBM dla bitmap i PGM dla map szarości. Jeśli podano argument filename
, mkbitmap
domyślnie utworzy plik wyjściowy, którego nazwa zostanie uzyskana z nazwy pliku wejściowego przez zmianę jego sufiksu na .pbm
. Na przykład w przypadku nazwy pliku wejściowego example.bmp
nazwa pliku wyjściowego będzie brzmieć example.pbm
.
Emscripten udostępnia wirtualny system plików, który symuluje lokalny system plików, dzięki czemu kod natywny korzystający z synchronicznych interfejsów API plików może być kompilowany i uruchamiany z niewielkimi lub żadnymi zmianami.
Aby mkbitmap
odczytał plik wejściowy tak, jakby został przekazany jako argument wiersza poleceń filename
, musisz użyć obiektu FS
udostępnianego przez Emscripten.
Obiekt FS
jest obsługiwany przez system plików w pamięci (powszechnie nazywany MEMFS) i ma funkcję writeFile()
, której używasz do zapisywania plików w systemie plików wirtualnych. Używasz writeFile()
w sposób pokazany w poniższym przykładowym kodzie.
Aby sprawdzić, czy operacja zapisu pliku się powiodła, uruchom funkcję readdir()
obiektu FS
z parametrem '/'
. Zobaczysz example.bmp
oraz kilka plików domyślnych, które są zawsze tworzone automatycznie.
Uwaga: poprzednie wywołanie funkcji Module.callMain(['-v'])
służące do drukowania numeru wersji zostało usunięte. Wynika to z tego, że funkcja Module.callMain()
jest zwykle wykonywana tylko raz.
// This is `script.js`.
import loadWASM from './mkbitmap.js';
const run = async () => {
const Module = await loadWASM();
const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
console.log(Module.FS.readdir('/'));
};
run();
Pierwsze faktyczne wykonanie
Po wykonaniu wszystkich czynności uruchom mkbitmap
, aby wykonać Module.callMain(['example.bmp'])
. Zaloguj zawartość folderu '/'
w MEMFS. Obok pliku wejściowego example.bmp
powinien pojawić się nowo utworzony plik wyjściowy example.pbm
.
// This is `script.js`.
import loadWASM from './mkbitmap.js';
const run = async () => {
const Module = await loadWASM();
const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
Module.callMain(['example.bmp']);
console.log(Module.FS.readdir('/'));
};
run();
Pobieranie pliku wyjściowego z systemu plików pamięci
Funkcja readFile()
obiektu FS
umożliwia pobranie example.pbm
utworzonego na ostatnim kroku z systemu plików pamięci. Funkcja zwraca Uint8Array
, który jest konwertowany na obiekt File
i zapisywany na dysku, ponieważ przeglądarki zwykle nie obsługują plików PBM do bezpośredniego wyświetlania w przeglądarce.
(istnieją bardziej eleganckie sposoby zapisu pliku, ale użycie dynamicznie utworzonego <a download>
jest najbardziej powszechnie obsługiwane). Po zapisaniu pliku możesz go otworzyć w ulubionym programie do wyświetlania obrazów.
// This is `script.js`.
import loadWASM from './mkbitmap.js';
const run = async () => {
const Module = await loadWASM();
const buffer = await fetch('https://example.com/example.bmp').then((res) => res.arrayBuffer());
Module.FS.writeFile('example.bmp', new Uint8Array(buffer));
Module.callMain(['example.bmp']);
const output = Module.FS.readFile('example.pbm', { encoding: 'binary' });
const file = new File([output], 'example.pbm', {
type: 'image/x-portable-bitmap',
});
const a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = file.name;
a.click();
};
run();
Dodawanie interaktywnego interfejsu
Do tego momentu plik wejściowy jest zakodowany na stałe, a mkbitmap
działa z domyślnymi parametrami. Ostatnim krokiem jest umożliwienie użytkownikowi dynamicznego wybrania pliku wejściowego, dostosowanie parametrów mkbitmap
, a następnie uruchomienie narzędzia z wybranymi opcjami.
// Corresponds to `mkbitmap -o output.pbm input.bmp -s 8 -3 -f 4 -t 0.45`.
Module.callMain(['-o', 'output.pbm', 'input.bmp', '-s', '8', '-3', '-f', '4', '-t', '0.45']);
Format obrazu PBM nie jest szczególnie trudny do przeanalizowania, więc za pomocą niewielkiego kodu JavaScript możesz nawet wyświetlić podgląd obrazu wyjściowego. Poniżej znajdziesz kod źródłowy zaimplementowanego przykładu, który pokazuje jedną z możliwości wykonania tej czynności.
Podsumowanie
Gratulacje! Udało Ci się skompilować mkbitmap
na WebAssembly i uruchomić w przeglądarce. W pewnych miejscach utknęłaś i musiałaś kompilować narzędzie więcej niż raz, zanim zaczęło działać, ale jak już pisałam powyżej, to jest część procesu. Jeśli utkniesz, pamiętaj też o tagu webassembly
w witrynie StackOverflow. Powodzenia!
Podziękowania
Ten artykuł został sprawdzony przez Samego Clegga i Rachel Andrew.