WASM? WebAssembly?
Lately I’ve started to ask myself: “Is WASM worth paying attention to?”
Let’s find out. There are few languages that can be directly compiled into WASM. Anyway let’s try GO.
We will make a simple web application that converts image from your webcam into ascii art. The goal is to write as much code in Go as possible.
Let’s go!
# go mod init asciifyme
And that’s it, everything works!
Just kidding. It’s not that simple.
We will need following pieces:
webcam
- will initialize and fetch image from web camcanvas
- we need this to fetch pixel data from imageasciifyier
- turn image data into stringThe webcam:
This module will:
video
element with document.createElement
navigator.getUserMedia
We need to create a file webcam/webcam.go
First part looks like this:
package webcam
import (
"fmt"
"syscall/js"
)
var (
navigator js.Value
video js.Value
)
func init() {
navigator = js.Global().Get("navigator")
video = js.Global().Get("document").Call("createElement", "video")
}
With this code we are creating video
element, and fetching navigator
for future use.
It’s time to setup webcam:
func Setup() js.Value {
user_media_params := map[string]interface{}{
"video": true,
}
navigator.Call("getUserMedia", user_media_params, js.FuncOf(stream), js.FuncOf(err))
return video
}
We will call this function from the main, it will setup webcam and return video
object to fetch data from.
But wait! There are two callbacks stream
and err
we need to implement:
func err(this js.Value, args []js.Value) interface{} {
fmt.Println("err")
return nil
}
func stream(this js.Value, args []js.Value) interface{} {
video.Set("srcObject", args[0])
video.Call("addEventListener", "canplaythrough", js.FuncOf(canPlay))
return nil
}
For now we will ignore errors and write error on console.
stream
function adds a stream to the video element and listen to canplaythrough
event.
Another callback? Yes! video
will call canPlay
callback when there will be enough data.
func canPlay(this js.Value, args []js.Value) interface{} {
video.Call("play")
return nil
}
When we have enough data press play!
We have a video
, now we need a pixel data. Let’s create canvas
in canvas/canvas.go
package canvas
import (
"syscall/js"
)
const (
CanvasWidth = 80
CanvasHeight = 40
)
var (
ctx js.Value
)
func init() {
ctx = js.Global().Get("document").Call("createElement", "canvas").Call("getContext", "2d")
}
We’re creating canvas
element and fetching context
. Will use it to draw and fetch pixel data.
func DrawImage(video js.Value) {
ctx.Call("drawImage", video, 0, 0, CanvasWidth, CanvasHeight)
}
We can draw a frame from video
by passing it into drawImage
function.
func GetImageData() []uint8 {
data := ctx.Call("getImageData", 0, 0, CanvasWidth, CanvasHeight).Get("data")
lenght := data.Get("length").Int()
goData := make([]uint8, lenght)
js.CopyBytesToGo(goData, data)
return goData
}
Fetching pixel data is more complicated. We have to fetch JS array of uint8
into GO.
This function takes the length of data from canvas
, create GO array, and copy whole data into go array.
Voila! We have a pixel data.
What’s left? Convert it to asciiart.
asciifyier/asciifyier.go
is what we need!
package asciifyier
import (
"asciifyme/canvas"
)
const (
Chars = " .,:;i1tfLCG08@"
CharsLength = 16
)
We don’t need any JS stuff here. But need to import our canvas
to fetch its size.
func Asciify(data []uint8) string {
output := ""
for y := 0; y < canvas.CanvasHeight; y++ {
for x := 0; x < canvas.CanvasWidth; x++ {
offset := (y*canvas.CanvasWidth + x) * 4
red := data[offset]
green := data[offset+1]
blue := data[offset+2]
//alpha := data[offset+3]
brightness := (0.3*float64(red) + 0.59*float64(green) + 0.11*float64(blue)) / 255.0
char_index := CharsLength - int(brightness*CharsLength)
output += string(Chars[char_index])
}
output += "\n"
}
return output
}
What we’re doing here? We’re taking each pixel data from array of uint8
and creating a string. Our asciiart.
It’s time for main.go
…
package main
import (
"asciifyme/asciifyier"
"asciifyme/canvas"
"asciifyme/webcam"
"syscall/js"
)
var (
camera js.Value
window js.Value
pre js.Value
)
func init() {
camera = webcam.Setup()
window = js.Global().Get("window")
pre = js.Global().Get("document").Call("getElementById", "pre")
}
Taking all the pieces together. We will need a camera
, window.requestAnimationFrame
, and pre
element to display our asciiart.
func loop(this js.Value, args []js.Value) interface{} {
window.Call("requestAnimationFrame", js.FuncOf(loop))
canvas.DrawImage(camera)
imageData := canvas.GetImageData()
output := asciifyier.Asciify(imageData)
pre.Set("innerHTML", output)
return nil
}
func main() {
loop(js.ValueOf(nil), make([]js.Value, 0))
select {}
}
In main loop we’re:
video
canvas
canvas
asciifyier
pre
One more thing! select {}
make the wasm program don’t quit!
That’s it. Compile time!
To run this in the browser we need:
Simple index.html
file
<html>
<head>
<title>asscify-me</title>
<style>
body{background-color:#000}pre{text-align:center}header{color:#daa520;font-size:18px;font-weight:700;text-shadow:0 0 3px gold}section{margin-top:30px;color:#32cd32;text-shadow:0 0 15px #0f0;font-size:14px}footer,footer a{margin-top:30px;color:red;text-shadow:0 0 15px tomato;font-size:14px}
</style>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("asciifyme.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
</head>
<body>
<pre id="pre"></pre>
</body>
</html>
And the build script:
#/bin/bash
export GOOS=js
export GOARCH=wasm
mkdir -p build
cp index.html build/
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" build/
go build -o build/asciifyme.wasm
Now we need to run:
# ./build.sh
Serve files from build
folder, and use the browser.
Notice that the browser will give camera access when you’re using https://
or localhost
P.S
But wait! The size! ~2MB is way to much! Yes!
Try thinygo
compiler, ~200KB is much better!
# tinygo build -o build/asciifyme.wasm -target wasm
Don’t need to write whole thing yourself if you don’t want. Check out my github or working an app.