Cache textures for performance boost #4

Open
opened 2 years ago by kirsle · 0 comments
kirsle commented 2 years ago
Owner

To improve UI toolkit performance, widgets shouldn't need to re-draw themselves from scratch every single frame. This is fine enough on desktop builds, but WASM builds quickly get bogged down with all the extra draw calls.

Consider the simple Button widget which incurs at least five draw ops per button per frame:

  1. Draw a solid black rectangle for the outline
  2. Draw a solid light grey rect for the highlight
  3. Draw a solid dark grey rect for the shadow
  4. Draw a solid grey rect for the button background color
  5. Draw the text for the button (possibly with additional draw calls for shadow or stroked text).

This ticket contains ideas and notes on how texture caching will work.

Prerequisite

The underlying go/render engine has a Texturer interface for holding cached textures (SDL2 or HTML Canvas), but the interface is currently optimized for holding onto generated PNG images.

The Texturer interface should be extended to support creating a "blank" texture and drawing to it manually in much the same way as the render.Engine interface itself: with functions like DrawLine(), DrawRect(), DrawText() and so on.

The UI toolkit can then direct its draw calls to a Texturer instead of directly to the Engine.

Implementation A.

Have a centralized module responsible for holding the cached textures. The advantage over Implementation B (widgets owning their own textures) is that it's easier to free unused textures when they're centralized whereas a garbage-collected widget may be trickier to free properly.

The UI library would get a source file like texture_cache.go holding centralized variables and methods, like:

// Global kill switch to disable texture caching if the caller wants to
var TextureCacheEnabled = true

// A hashmap to keep cached textures on behalf of the widgets.
// In actuality, this may be an LRU cache where infrequently
// accessed items expire out and get freed.
var textures map[string]render.Texturer

// A function to get a texture. The key(s) are any JSON serializable
// object conveying the unique properties that identify the desired
// texture apart from the others. Returns the texture (if exists) and
// a bool whether it existed.
func GetCachedTexture(key ...interface{}) (render.Texturer, bool)

// Store a texture into the cache.
func StoreCachedTexture(key ...interface{}, render.Texturer)

// Invalidate all cached textures
func FlushCachedTextures() int

The key(s) for the texture cache would usually be the ui.Widget struct directly, with all of its configuration (width, height, text variable, fonts, etc.) and in case a widget has multiple states (e.g. a Button having hover and mouse down states) they could append additional keys such as a string or int value for the state they want:

// A Button wanting to get its cached "pressed down" texture
tex, ok := GetCachedTexture(w, "down")
if !ok {
    // generate and store the texture
    tex = w.drawDownTexture()
    StoreCachedTexture(w, "down", tex)
}

Basically: if the cacher doesn't have your texture it returns nil/false and you generate it yourself and then store it in the cacher, so on your next check you can get back the texture you stored.

The cache key algorithm would be basically:

  • Take all the json-serializable key values and encode them to deterministic JSON strings (sorted keys, etc.), appending each key to one another in a string value.
  • Hash the string using SHA-1 or something decent to use as the map key value.

Hopefully, widgets having a TextVariable or IntVariable (pointer to data) in their struct will have those values correctly conveyed in the key, so multiple Labels having similar properties but capturing different text contents will be appropriately keyed distinctly in the cache.

Implementation B.

High level overview is:

  • Each widget is responsible for its own texture cache.
  • Widgets would keep a "dirty" flag to invalidate the cache and re-draw itself from scratch.
  • Their Present() method would be updated to draw the cached texture if available or redraw from scratch if missing/dirty.

For example with the Label widget:

type Label struct {
    // set to true when the Label needs to re-draw and
    // invalidate its cached texture
    dirty bool
    
    // cached texture for the Label
    texture render.Texturer
}

func (w *Label) Present(e render.Engine, p render.Point) {
    tex := w.texture
    
    // if no cached texture OR dirty flag is set, draw the
    // texture from scratch.
    if tex == nil || w.dirty {
    	tex = e.NewTexture()
    	tex.DrawText(...)
    	w.texture = tex
    }
    
    // paste the texture onto the window
    e.Copy(tex, ...)
}

So for the best case scenario where a texture is already cached, each call to Present() just blits the texture to the screen in a single draw call.

The dirty flag would be set for cases such as:

  • If a TextVariable or IntVariable is used in a widget and the variable's value has changed.
  • If the widget is reconfigured (changed border type or size, changed colors, etc.)

Since caching is up to the widgets, some widgets can maintain multiple cached states, for example Button might keep caches for their normal, hover, and pressed states whereas Label only needs one texture.

To improve UI toolkit performance, widgets shouldn't need to re-draw themselves from scratch every single frame. This is fine enough on desktop builds, but WASM builds quickly get bogged down with all the extra draw calls. Consider the simple Button widget which incurs at least five draw ops per button per frame: 1. Draw a solid black rectangle for the outline 2. Draw a solid light grey rect for the highlight 3. Draw a solid dark grey rect for the shadow 4. Draw a solid grey rect for the button background color 5. Draw the text for the button (possibly with additional draw calls for shadow or stroked text). This ticket contains ideas and notes on how texture caching will work. ### Prerequisite The underlying [go/render](https://git.kirsle.net/go/render) engine has a Texturer interface for holding cached textures (SDL2 or HTML Canvas), but the interface is currently optimized for holding onto generated PNG images. The Texturer interface should be extended to support creating a "blank" texture and drawing to it manually in much the same way as the render.Engine interface itself: with functions like DrawLine(), DrawRect(), DrawText() and so on. The UI toolkit can then direct its draw calls to a Texturer instead of directly to the Engine. ### Implementation A. Have a centralized module responsible for holding the cached textures. The advantage over Implementation B (widgets owning their own textures) is that it's easier to free unused textures when they're centralized whereas a garbage-collected widget may be trickier to free properly. The UI library would get a source file like `texture_cache.go` holding centralized variables and methods, like: ```go // Global kill switch to disable texture caching if the caller wants to var TextureCacheEnabled = true // A hashmap to keep cached textures on behalf of the widgets. // In actuality, this may be an LRU cache where infrequently // accessed items expire out and get freed. var textures map[string]render.Texturer // A function to get a texture. The key(s) are any JSON serializable // object conveying the unique properties that identify the desired // texture apart from the others. Returns the texture (if exists) and // a bool whether it existed. func GetCachedTexture(key ...interface{}) (render.Texturer, bool) // Store a texture into the cache. func StoreCachedTexture(key ...interface{}, render.Texturer) // Invalidate all cached textures func FlushCachedTextures() int ``` The key(s) for the texture cache would usually be the ui.Widget struct directly, with all of its configuration (width, height, text variable, fonts, etc.) and in case a widget has multiple states (e.g. a Button having hover and mouse down states) they could append additional keys such as a string or int value for the state they want: ```go // A Button wanting to get its cached "pressed down" texture tex, ok := GetCachedTexture(w, "down") if !ok { // generate and store the texture tex = w.drawDownTexture() StoreCachedTexture(w, "down", tex) } ``` Basically: if the cacher doesn't have your texture it returns nil/false and you generate it yourself and then store it in the cacher, so on your next check you can get back the texture you stored. The cache key algorithm would be basically: * Take all the json-serializable key values and encode them to deterministic JSON strings (sorted keys, etc.), appending each key to one another in a string value. * Hash the string using SHA-1 or something decent to use as the map key value. Hopefully, widgets having a TextVariable or IntVariable (pointer to data) in their struct will have those values correctly conveyed in the key, so multiple Labels having similar properties but capturing different text contents will be appropriately keyed distinctly in the cache. ### Implementation B. High level overview is: * Each widget is responsible for its own texture cache. * Widgets would keep a "dirty" flag to invalidate the cache and re-draw itself from scratch. * Their Present() method would be updated to draw the cached texture if available or redraw from scratch if missing/dirty. For example with the Label widget: ```go type Label struct { // set to true when the Label needs to re-draw and // invalidate its cached texture dirty bool // cached texture for the Label texture render.Texturer } func (w *Label) Present(e render.Engine, p render.Point) { tex := w.texture // if no cached texture OR dirty flag is set, draw the // texture from scratch. if tex == nil || w.dirty { tex = e.NewTexture() tex.DrawText(...) w.texture = tex } // paste the texture onto the window e.Copy(tex, ...) } ``` So for the best case scenario where a texture is already cached, each call to Present() just blits the texture to the screen in a single draw call. The `dirty` flag would be set for cases such as: * If a TextVariable or IntVariable is used in a widget and the variable's value has changed. * If the widget is reconfigured (changed border type or size, changed colors, etc.) Since caching is up to the widgets, some widgets can maintain multiple cached states, for example Button might keep caches for their normal, hover, and pressed states whereas Label only needs one texture.
kirsle added the
enhancement
label 2 years ago
Sign in to join this conversation.
No Milestone
No Assignees
1 Participants
Notifications
Due Date

No due date set.

Dependencies

No dependencies set.

Reference: go/ui#4
Loading…
There is no content yet.