Texture Caching for WASM Canvas Engine

* Add RGBA color blending support in WASM build.
* Initial texture caching API for Canvas renderer engine. The WASM build
  writes the chunk caches as a "data:image/png" base64 URL on the
  browser's sessionStorage, for access to copy into the Canvas.
* Separated the ClickEvent from the MouseEvent (motion) in the WASM
  event queue system, to allow clicking and dragging.
* Added the EscapeKey handler, which will abruptly terminate the WASM
  application, same as it kills the window in the desktop build.
* Optimization fix: I discovered that if the user clicks and holds over
  a single pixel when drawing a level, repeated Set() operations were
  firing meaning multiple cache invalidations. Not noticeable on PC but
  on WebAssembly it crippled the browser. Now if the cursor isn't moving
  it doesn't do anything.
This commit is contained in:
Noah 2019-06-26 22:44:08 -07:00
parent c5c85330de
commit 48fc40ade4
12 changed files with 182 additions and 39 deletions

View File

@ -1,6 +1,7 @@
package canvas
import (
"fmt"
"syscall/js"
"git.kirsle.net/apps/doodle/lib/render"
@ -8,9 +9,19 @@ import (
// Methods here implement the drawing functions of the render.Engine
// RGBA turns a color into CSS RGBA string.
func RGBA(c render.Color) string {
return fmt.Sprintf("rgba(%d,%d,%d,%f)",
c.Red,
c.Green,
c.Blue,
float64(c.Alpha)/255,
)
}
// Clear the canvas to a certain color.
func (e *Engine) Clear(color render.Color) {
e.canvas.ctx2d.Set("fillStyle", color.ToHex())
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
e.canvas.ctx2d.Call("fillRect", 0, 0, e.width, e.height)
}
@ -21,7 +32,7 @@ func (e *Engine) SetTitle(title string) {
// DrawPoint draws a pixel.
func (e *Engine) DrawPoint(color render.Color, point render.Point) {
e.canvas.ctx2d.Set("fillStyle", color.ToHex())
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
e.canvas.ctx2d.Call("fillRect",
int(point.X),
int(point.Y),
@ -32,7 +43,7 @@ func (e *Engine) DrawPoint(color render.Color, point render.Point) {
// DrawLine draws a line between two points.
func (e *Engine) DrawLine(color render.Color, a, b render.Point) {
e.canvas.ctx2d.Set("fillStyle", color.ToHex())
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
for pt := range render.IterLine2(a, b) {
e.canvas.ctx2d.Call("fillRect",
int(pt.X),
@ -45,7 +56,7 @@ func (e *Engine) DrawLine(color render.Color, a, b render.Point) {
// DrawRect draws a rectangle.
func (e *Engine) DrawRect(color render.Color, rect render.Rect) {
e.canvas.ctx2d.Set("strokeStyle", color.ToHex())
e.canvas.ctx2d.Set("strokeStyle", RGBA(color))
e.canvas.ctx2d.Call("strokeRect",
int(rect.X),
int(rect.Y),
@ -56,7 +67,7 @@ func (e *Engine) DrawRect(color render.Color, rect render.Rect) {
// DrawBox draws a filled rectangle.
func (e *Engine) DrawBox(color render.Color, rect render.Rect) {
e.canvas.ctx2d.Set("fillStyle", color.ToHex())
e.canvas.ctx2d.Set("fillStyle", RGBA(color))
e.canvas.ctx2d.Call("fillRect",
int(rect.X),
int(rect.Y),

View File

@ -1,10 +1,13 @@
package canvas
import (
"errors"
"syscall/js"
"time"
"git.kirsle.net/apps/doodle/lib/events"
"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/pkg/wasm"
)
// Engine implements a rendering engine targeting an HTML canvas for
@ -63,12 +66,53 @@ func (e *Engine) Present() error {
return nil
}
func (e *Engine) NewBitmap(filename string) (render.Texturer, error) {
return nil, nil
// Texture can hold on to cached image textures.
type Texture struct {
data string // data:image/png URI
image js.Value // DOM image element
width int
height int
}
func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) {
// Size returns the dimensions of the texture.
func (t *Texture) Size() render.Rect {
return render.NewRect(int32(t.width), int32(t.height))
}
// NewBitmap initializes a texture from a bitmap image. The image is stored
// in HTML5 Session Storage.
func (e *Engine) NewBitmap(filename string) (render.Texturer, error) {
if data, ok := wasm.GetSession(filename); ok {
img := js.Global().Get("document").Call("createElement", "img")
img.Set("src", data)
return &Texture{
data: data,
image: img,
width: 60, // TODO
height: 60,
}, nil
}
return nil, errors.New("no bitmap data stored for " + filename)
}
var TODO int
// Copy a texturer bitmap onto the canvas.
func (e *Engine) Copy(t render.Texturer, src, dist render.Rect) {
tex := t.(*Texture)
// image := js.Global().Get("document").Call("createElement", "img")
// image.Set("src", tex.data)
// log.Info("drawing image just this once")
e.canvas.ctx2d.Call("drawImage", tex.image, dist.X, dist.Y)
// TODO++
// if TODO > 200 {
// log.Info("I exited at engine.Copy for canvas engine")
// os.Exit(0)
// }
}
// Delay for a moment.

View File

@ -4,7 +4,6 @@ import (
"syscall/js"
"git.kirsle.net/apps/doodle/lib/events"
"git.kirsle.net/apps/doodle/pkg/log"
)
// EventClass to categorize JavaScript events.
@ -13,6 +12,7 @@ type EventClass int
// EventClass values.
const (
MouseEvent EventClass = iota
ClickEvent
KeyEvent
ResizeEvent
)
@ -69,8 +69,6 @@ func (e *Engine) AddEventListeners() {
which = args[0].Get("which").Int()
)
log.Info("Clicked at %d,%d", x, y)
// Is a mouse button pressed down?
checkDown := func(number int) bool {
if which == number {
@ -81,7 +79,7 @@ func (e *Engine) AddEventListeners() {
e.queue <- Event{
Name: ev,
Class: MouseEvent,
Class: ClickEvent,
X: x,
Y: y,
LeftClick: checkDown(1),
@ -149,22 +147,26 @@ func (e *Engine) PollEvent() *Event {
func (e *Engine) Poll() (*events.State, error) {
s := e.events
if e.events.EnterKey.Now {
log.Info("saw enter key here, good")
}
if e.events.KeyName.Now == "h" {
log.Info("saw letter h here, good")
}
for event := e.PollEvent(); event != nil; event = e.PollEvent() {
switch event.Class {
case MouseEvent:
s.CursorX.Push(int32(event.X))
s.CursorY.Push(int32(event.Y))
case ClickEvent:
s.CursorX.Push(int32(event.X))
s.CursorY.Push(int32(event.Y))
s.Button1.Push(event.LeftClick)
s.Button2.Push(event.RightClick)
case KeyEvent:
switch event.KeyName {
case "Escape":
if event.Repeat {
continue
}
if event.State {
s.EscapeKey.Push(true)
}
case "Enter":
if event.Repeat {
continue
@ -196,15 +198,12 @@ func (e *Engine) Poll() (*events.State, error) {
s.KeyName.Push(`\b`)
}
default:
log.Info("default handler, push key %s", event.KeyName)
if event.State {
s.KeyName.Push(event.KeyName)
} else {
s.KeyName.Push("")
}
}
log.Info("event end, stored key=%s", s.KeyName.Now)
}
}

View File

@ -105,9 +105,6 @@ func (d *Doodle) Run() error {
start := time.Now() // Record how long this frame took.
d.ticks++
if d.ticks%100 == 0 {
log.Info("...tick...%d", d.ticks)
}
// Poll for events.
ev, err := d.Engine.Poll()

View File

@ -6,14 +6,15 @@ import (
"image"
"math"
"os"
"runtime"
"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/pkg/balance"
"git.kirsle.net/apps/doodle/pkg/log"
"git.kirsle.net/apps/doodle/pkg/userdir"
"git.kirsle.net/apps/doodle/pkg/wasm"
"github.com/satori/go.uuid"
"github.com/vmihailenco/msgpack"
"golang.org/x/image/bmp"
)
// Types of chunks.
@ -80,7 +81,9 @@ func (c *Chunk) Texture(e render.Engine) render.Texturer {
if c.texture == nil || c.dirty {
// Generate the normal bitmap and one with a color mask if applicable.
bitmap := c.toBitmap(render.Invisible)
defer os.Remove(bitmap)
if runtime.GOOS != "js" { // WASM
defer os.Remove(bitmap)
}
tex, err := e.NewBitmap(bitmap)
if err != nil {
log.Error("Texture: %s", err)
@ -98,7 +101,9 @@ func (c *Chunk) TextureMasked(e render.Engine, mask render.Color) render.Texture
if c.textureMasked == nil || c.textureMaskedColor != mask {
// Generate the normal bitmap and one with a color mask if applicable.
bitmap := c.toBitmap(mask)
defer os.Remove(bitmap)
if runtime.GOOS != "js" { // WASM
defer os.Remove(bitmap)
}
tex, err := e.NewBitmap(bitmap)
if err != nil {
log.Error("Texture: %s", err)
@ -125,8 +130,6 @@ func (c *Chunk) toBitmap(mask render.Color) string {
)
}
log.Info("Chunk<%d>.toBitmap() called", c.Size)
// Get the temp bitmap image.
bitmap := userdir.CacheFilename("chunk", filename+".bmp")
err := c.ToBitmap(bitmap, mask)
@ -184,13 +187,8 @@ func (c *Chunk) ToBitmap(filename string, mask render.Color) error {
)
}
fh, err := os.Create(filename)
if err != nil {
return err
}
defer fh.Close()
return bmp.Encode(fh, img)
// Write the image to bitmap file.
return wasm.StoreBitmap(filename, img)
}
// Set proxies to the accessor and flags the texture as dirty.

View File

@ -35,6 +35,15 @@ func (w *Canvas) loopEditable(ev *events.State) error {
Swatch: w.Palette.ActiveSwatch,
}
// If the user is holding the mouse down over one spot and not
// moving, don't do anything. The pixel has already been set and
// needless writes to the map cause needless cache rewrites etc.
if lastPixel != nil {
if pixel.X == lastPixel.X && pixel.Y == lastPixel.Y {
break
}
}
// Append unique new pixels.
if len(w.pixelHistory) == 0 || w.pixelHistory[len(w.pixelHistory)-1] != pixel {
if lastPixel != nil {

View File

@ -65,7 +65,10 @@ func DoodadPath(filename string) string {
func CacheFilename(filename ...string) string {
paths := append([]string{CacheDirectory}, filename...)
dir := paths[:len(paths)-1]
configdir.MakePath(filepath.Join(dir...))
if runtime.GOOS != "js" {
configdir.MakePath(filepath.Join(dir...))
}
return filepath.Join(paths[0], filepath.Join(paths[1:]...))
}

20
pkg/wasm/bitmap.go Normal file
View File

@ -0,0 +1,20 @@
// +build !js
package wasm
import (
"image"
"os"
"golang.org/x/image/bmp"
)
// StoreBitmap stores a bitmap image to disk.
func StoreBitmap(filename string, img image.Image) error {
fh, err := os.Create(filename)
if err != nil {
return err
}
defer fh.Close()
return bmp.Encode(fh, img)
}

25
pkg/wasm/bitmap_js.go Normal file
View File

@ -0,0 +1,25 @@
// +build js,wasm
package wasm
import (
"bytes"
"encoding/base64"
"image"
"image/png"
)
// StoreBitmap stores a bitmap image to sessionStorage as a data URL for PNG
// base64 encoded image.
func StoreBitmap(filename string, img image.Image) error {
var fh = bytes.NewBuffer([]byte{})
if err := png.Encode(fh, img); err != nil {
return err
}
var dataURI = "data:image/png;base64," + base64.StdEncoding.EncodeToString(fh.Bytes())
SetSession(filename, dataURI)
return nil
}

14
pkg/wasm/localstorage.go Normal file
View File

@ -0,0 +1,14 @@
// +build !js
package wasm
// SetSession sets a binary value on sessionStorage.
// This is a no-op when not in wasm.
func SetSession(key string, value string) {
}
// GetSession retrieves a binary value from sessionStorage.
// This is a no-op when not in wasm.
func GetSession(key string) (string, bool) {
return "", false
}

View File

@ -0,0 +1,20 @@
// +build js,wasm
package wasm
import (
"syscall/js"
)
// SetSession sets a text value on sessionStorage.
func SetSession(key string, value string) {
// b64 := base64.StdEncoding.EncodeToString(value)
js.Global().Get("sessionStorage").Call("setItem", key, value)
}
// GetSession retrieves a text value from sessionStorage.
func GetSession(key string) (string, bool) {
var value js.Value
value = js.Global().Get("sessionStorage").Call("getItem", key)
return value.String(), value.Type() == js.TypeString
}

View File

@ -5,6 +5,8 @@ package main
import (
"fmt"
"syscall/js"
"git.kirsle.net/apps/doodle/lib/render"
"git.kirsle.net/apps/doodle/lib/render/canvas"
doodle "git.kirsle.net/apps/doodle/pkg"
@ -19,6 +21,7 @@ func main() {
// Enable workarounds.
balance.DisableChunkTextureCache = true
js.Global().Get("sessionStorage").Call("clear")
// HTML5 Canvas engine.
engine, _ := canvas.New("canvas")
@ -35,7 +38,7 @@ func main() {
game.SetWindowSize(w, h)
// game.Goto(&doodle.GUITestScene{})
// game.Goto(&doodle.EditorScene{})
game.Goto(&doodle.EditorScene{})
game.Run()
}