Example examples/assets
Assets

This program is the asset package end to end. It seeds a directory with twelve generated PNGs, opens it as an asset filesystem, decodes the images on worker goroutines behind a progress bar, creates the textures on the main thread as each decode finishes, watches the files and reloads any that change, and packs the whole directory into a pack file on demand. It also rewrites one image every two seconds so the reload path can be seen working without anyone editing a file.
Two smaller packages appear alongside it. save keeps a settings file that counts how many times the example has been run, and timer schedules the rewrites. Both are covered in the game services guide.
The division of labour is the point. Decoding is slow and happens on
worker goroutines; creating a GPU texture must happen on the main
goroutine, so it happens in Update as each handle reports a value. A
game never creates or destroys a GPU resource from another goroutine.
Run it:
go run ./examples/assets -seconds 3 -shot out.png
The flags are -seconds N, -shot file.png, -dir path, which is
the asset directory; it defaults to a directory under the system
temporary directory and is created and seeded when empty, and -seed N,
which decides which image the timer rewrites, so a run can be repeated.
P packs the directory and Escape quits.
Package and state
settings is the shape of the saved file, with a version number so a
later format can be told apart. item is one image: the name it is
loaded under, the handle while the load is in flight, and the texture
once it has been created. Exactly one of the two is set at a time, which
is what the reload logic tests.
// Command assets shows the asset and save packages: a directory of
// images is decoded on worker goroutines behind a progress bar, textures
// are created on the main thread as each finishes, files that change on
// disk are reloaded live (the example rewrites one every two seconds to
// prove it), P packs the directory into assets.pak, and a settings file
// remembers how many times the example has run.
package main
import (
"bytes"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"time"
"golang.org/x/image/font/gofont/goregular"
"github.com/matjam/bunyip/asset"
"github.com/matjam/bunyip/engine"
"github.com/matjam/bunyip/gfx"
"github.com/matjam/bunyip/input"
"github.com/matjam/bunyip/lin"
"github.com/matjam/bunyip/rng"
"github.com/matjam/bunyip/save"
"github.com/matjam/bunyip/timer"
"github.com/matjam/bunyip/ui"
)
const count = 12
type settings struct {
Version int
Runs int
Dir string
}
type item struct {
name string
handle *asset.Handle[image.Image]
tex *gfx.Texture
}
type game struct {
seconds float64
shot string
dir string
randSeed uint64
font *gfx.Font
ui *ui.Context
fs *asset.FS
loader *asset.Loader
watcher *asset.Watcher
items []*item
timers timer.Scheduler
random *rng.Rand
store *save.Store
settings settings
status string
packed string
shotDone bool
}
Init: settings, sources, loader and watcher
save.Open returns a store in the operating system's own data directory
for the named application. Store.Load reads a value or returns the
default it is given when there is no file yet, which is how a first run
starts from a known state rather than from an error.
asset.Open takes any number of sources and searches them in order, so
listing the directory before the pack file means a loose file wins over
the packed copy of the same name. That is the shape a game ships with:
the pack for the release, loose files for whatever is being edited.
asset.NewLoader(g.fs, 0) starts the worker pool; zero means one worker
per CPU. Loader.Load queues one file with the
function that decodes its bytes and returns an
*asset.Handle[image.Image], a typed handle that reports when the value
is ready. asset.NewWatcher polls the filesystem at the interval given,
and Add names a file to watch.
g.timers.Every(2, ...) schedules a repeating callback on the game's own
scheduler, which is stepped from Update.
func (g *game) Init(ctx *engine.Context) error {
var err error
if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 15, gfx.FontOptions{}); err != nil {
return err
}
g.ui = ui.New(ctx.Gfx, ui.DarkTheme(g.font))
// A seed from the flag rather than the clock, so a run can be
// repeated: which image the timer rewrites is then the same every
// time, which is what a test comparing frames needs.
g.random = rng.New(g.randSeed)
// Settings persist between runs in the platform's data directory.
if g.store, err = save.Open("bunyip-assets"); err != nil {
return err
}
if g.settings, err = g.store.Load("settings", settings{Version: 1}); err != nil {
return err
}
g.settings.Runs++
g.settings.Dir = g.dir
if err := g.store.Write("settings", g.settings); err != nil {
return err
}
if err := g.seed(); err != nil {
return err
}
sources := []string{g.dir}
if pack := filepath.Join(g.dir, "assets.pak"); exists(pack) {
sources = append(sources, pack) // loose files win over the pack
}
if g.fs, err = asset.Open(sources...); err != nil {
return err
}
g.loader = asset.NewLoader(g.fs, 0)
g.watcher = asset.NewWatcher(g.fs, 250*time.Millisecond)
for i := range count {
name := fmt.Sprintf("images/shape%02d.png", i)
g.items = append(g.items, &item{name: name, handle: g.loader.Load(name, decode)})
g.watcher.Add(name)
}
// Rewrite a random image now and then; the watcher reloads it.
g.timers.Every(2, func() {
i := g.random.Intn(count)
g.writeImage(i, g.random)
g.status = fmt.Sprintf("Rewrote %s on disk", g.items[i].name)
})
g.status = fmt.Sprintf("Run %d of this example; assets in %s", g.settings.Runs, g.dir)
return nil
}
decode is the loader function: bytes in, value out, on a worker
goroutine. It must not touch the graphics device, which is why it returns
an image.Image rather than a texture.
func decode(data []byte) (image.Image, error) {
img, _, err := image.Decode(bytes.NewReader(data))
return img, err
}
func exists(p string) bool { _, err := os.Stat(p); return err == nil }
Seeding the directory
The rest of the setup is this program making itself something to load.
seed writes any of the twelve images that are missing, and writeImage
draws a circle, a square or a diamond in two colours and encodes it as a
PNG. A real game ships its assets instead.
// seed creates the asset directory with generated images when empty.
func (g *game) seed() error {
if err := os.MkdirAll(filepath.Join(g.dir, "images"), 0o755); err != nil {
return err
}
r := rng.New(5)
for i := range count {
if !exists(filepath.Join(g.dir, fmt.Sprintf("images/shape%02d.png", i))) {
if err := g.writeImage(i, r); err != nil {
return err
}
}
}
return nil
}
func (g *game) writeImage(i int, r *rng.Rand) error {
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
bg := color.RGBA{uint8(r.Intn(200)), uint8(r.Intn(200)), uint8(r.Intn(200)), 255}
fg := color.RGBA{255 - bg.R, 255 - bg.G, 255 - bg.B, 255}
kind := r.Intn(3)
for y := range 64 {
for x := range 64 {
dx, dy := x-32, y-32
inside := false
switch kind {
case 0:
inside = dx*dx+dy*dy < 22*22
case 1:
inside = abs(dx) < 20 && abs(dy) < 20
default:
inside = abs(dx)+abs(dy) < 24
}
c := bg
if inside {
c = fg
}
img.SetRGBA(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
return os.WriteFile(filepath.Join(g.dir, fmt.Sprintf("images/shape%02d.png", i)), buf.Bytes(), 0o644)
}
func abs(v int) int {
if v < 0 {
return -v
}
return v
}
Shutdown: closing in order
The watcher and the loader are closed before the filesystem they read
from, and every texture that was created is destroyed. Loader.Close
stops submissions and waits for queued work and the workers to finish,
so closing the filesystem next is safe even while loads are pending.
func (g *game) Shutdown(ctx *engine.Context) {
g.watcher.Close()
g.loader.Close()
g.fs.Close()
for _, it := range g.items {
if it.tex != nil {
it.tex.Destroy()
}
}
g.font.Destroy()
}
Update: finishing loads and reloads
handle.Value() returns the value, the error and whether the load has
finished. It never blocks, so calling it once per update is how a game
picks up finished work. A finished handle is dropped and the texture
created here, on the main goroutine, from the image the worker decoded.
watcher.Changed() returns the names whose files changed since the last
call. A changed item's texture is destroyed and a fresh load queued for
it, which puts the item back into the loading state the first branch
handles. Guarding on it.handle == nil keeps a second reload from
starting while one is still in flight.
asset.Pack writes a directory into a single pack file. The next run
finds it beside the loose files and reads both.
func (g *game) Update(ctx *engine.Context) error {
if ctx.Input.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
ctx.Quit()
}
if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
ctx.Screenshot(g.shot)
g.shotDone = true
}
g.timers.Update(ctx.Delta)
// Finished loads become textures here, on the main thread.
for _, it := range g.items {
if it.tex != nil || it.handle == nil {
continue
}
if img, err, ok := it.handle.Value(); ok {
it.handle = nil
if err != nil {
g.status = err.Error()
continue
}
it.tex, err = ctx.Gfx.NewTexture(img, gfx.TextureOptions{})
if err != nil {
return err
}
}
}
for _, name := range g.watcher.Changed() {
for _, it := range g.items {
if it.name == name && it.handle == nil {
if it.tex != nil {
it.tex.Destroy()
it.tex = nil
}
it.handle = g.loader.Load(name, decode)
g.status = "Reloaded " + name
}
}
}
if ctx.Input.KeyPressed(input.KeyP) {
out := filepath.Join(g.dir, "assets.pak")
if err := asset.Pack(filepath.Join(g.dir), out); err != nil {
g.status = err.Error()
} else {
info, _ := os.Stat(out)
g.packed = fmt.Sprintf("Packed %d bytes into %s", info.Size(), out)
}
}
return nil
}
Draw: the grid and the panel
Each item is a sprite when its texture exists and a flat rectangle while
it is still loading. gfx.Sprite positions are view units from the top
left, UV1: lin.V2(1, 1) uses the whole texture, and gfx.White is the
untinted colour; leaving Color zero would mean the same thing, since a
zero colour where a tint is expected is white.
u.Progress draws a bar from a fraction, which comes straight from
loader.Progress(): the number of loads finished and the number queued.
The max(total, 1) avoids dividing by zero before anything is queued.
func (g *game) Draw(ctx *engine.Context) error {
gr := ctx.Gfx
for i, it := range g.items {
x := 40 + float32(i%6)*130
y := 140 + float32(i/6)*130
if it.tex != nil {
gr.Draw(it.tex, gfx.Sprite{Pos: lin.V2(x, y), Size: lin.V2(96, 96), UV1: lin.V2(1, 1), Color: gfx.White})
} else {
gr.FillRect(x, y, 96, 96, gfx.RGB(40, 40, 50))
}
gr.DrawText(g.font, filepath.Base(it.name), x, y+100, gfx.RGB(170, 170, 190))
}
u := g.ui
u.Begin(ctx.Input, func() {
u.Panel("Assets", ui.Rect{X: 16, Y: 16, W: 800, H: 110}, func() {
done, total := g.loader.Progress()
u.Progress(fmt.Sprintf("Loaded %d of %d", done, total), float32(done)/float32(max(total, 1)))
u.Label(g.status)
if g.packed != "" {
u.Label(g.packed)
} else {
u.Label("P packs the directory into assets.pak, read on the next run behind the loose files.")
}
})
})
return nil
}
main
func main() {
seconds := flag.Float64("seconds", 0, "exit after this many seconds")
shot := flag.String("shot", "", "write a screenshot to this PNG")
dir := flag.String("dir", filepath.Join(os.TempDir(), "bunyip-assets"), "asset directory (created and seeded when empty)")
seed := flag.Uint64("seed", 5, "random seed, so a run can be repeated")
flag.Parse()
err := engine.Run(engine.Config{Title: "Bunyip assets", Width: 840, Height: 440},
&game{seconds: *seconds, shot: *shot, dir: *dir, randSeed: *seed})
if err != nil {
fmt.Fprintln(os.Stderr, "assets:", err)
os.Exit(1)
}
}
What to try
- Run it twice and watch the run counter in the panel, then delete the
settings file the path in
Initnames. - Press P and inspect the pack file. Loose files take precedence when
asset.Openfinds both sources; creating a pack alone does not switch these images away from the asset directory. - Edit one of the PNGs in the asset directory with an image editor and
watch the watcher in
Updatereload it within a quarter second. - Change the watcher's interval in
Initand see the reload latency follow it. - Make
decodereturn an error for one name and watch the status line report it without stopping the other loads.