Bunyip a game engine in Go GitHub

Package github.com/matjam/bunyip/asset

asset

Package asset finds and loads a game's files. Sources are loose directories while developing, pack files when shipping, files embedded in the binary with go:embed, or any mix, with earlier sources taking precedence so a modder's or developer's copy overrides the packed or embedded one. Open takes directory and pack paths; OpenFS also takes an io/fs.FS. FS implements io/fs.FS with merged directories; loaders also accept embed.FS, os.DirFS and other fs.FS values directly. The one-call loaders (Image, Texture, Atlas, Aseprite, Font, Sound, Music, Model, Tracker, Scene, Prefab) read and decode an asset into an engine object. A Loader decodes assets on worker goroutines for loading screens, a Watcher reports loose files that change on disk, and a Reloader swaps a changed file's texture or shader into the objects a game already holds, so hot reload needs no bookkeeping in the game. GPU loaders and Reloader methods run on the rendering goroutine. Closing FS releases pack handles, not loaded resources. Graphics owns uploaded GPU resources; Destroy releases them early. The caller closes loaded music and other resources with independent lifetimes.

Index

Examples

Example
package main

import (
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"github.com/matjam/bunyip/asset"
)

func main() {
	// A directory of loose files while developing; ship a pack file built
	// with bunyip-pack and open both, directory first, so loose files win.
	dir, _ := os.MkdirTemp("", "asset-example")
	defer os.RemoveAll(dir)
	os.MkdirAll(filepath.Join(dir, "text"), 0o755)
	os.WriteFile(filepath.Join(dir, "text", "intro.txt"), []byte("Once upon a time"), 0o644)
	asset.Pack(dir, filepath.Join(dir, "..", "example.pak"))
	defer os.Remove(filepath.Join(dir, "..", "example.pak"))

	fs, err := asset.Open(dir, filepath.Join(dir, "..", "example.pak"))
	if err != nil {
		panic(err)
	}
	defer fs.Close()
	data, _ := fs.Read("text/intro.txt")
	fmt.Println(string(data))
	fmt.Println(fs.List(""))

	// Decode on worker goroutines; poll Ready from the game loop, or Wait.
	loader := asset.NewLoader(fs, 0)
	defer loader.Close()
	upper := loader.Load("text/intro.txt", func(b []byte) (string, error) { return strings.ToUpper(string(b)), nil })
	v, _ := upper.Get()
	fmt.Println(v)
}
Output
Once upon a time
[text/intro.txt]
ONCE UPON A TIME

Variables

var ErrNotFound = fs.ErrNotExist

ErrNotFound is returned for names no source holds.

Functions

Aseprite source

func Aseprite(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.AsepriteOptions, texOpts gfx.TextureOptions) (*gfx.Aseprite, error)

Aseprite reads an .aseprite or .ase file, composites each frame from its visible layers, uploads the packed image and binds an atlas: one call where a game would otherwise parse, pack, upload and bind by hand. The atlas is on the result's Atlas field, its frames are named by number and its tags play through Atlas.Animation; the result also carries the file's layers, tags, slices and palette.

Atlas source

func Atlas(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.TextureOptions) (*gfx.Atlas, error)

Atlas reads a TexturePacker or Aseprite JSON atlas, loads the image it names from the same directory, uploads it and binds the frames: one call where a game would otherwise parse, load and bind by hand.

Emitter source

func Emitter(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.TextureOptions) (particle.Emitter, error)

Emitter reads a particle emitter saved as JSON and loads the texture it names, from the same directory as the emitter file. An emitter naming no texture comes back drawing plain quads. Destroy the texture with the rest of the game's resources; it is the returned emitter's Texture.

Font source

func Font(g *gfx.Graphics, fsys fs.FS, name string, size float32, opts gfx.FontOptions) (*gfx.Font, error)

Font reads a TTF or OTF file and prepares a bitmap atlas at size.

Image source

func Image(fsys fs.FS, name string) (image.Image, error)

Image reads and decodes a PNG or JPEG.

Model source

func Model(g *gfx.Graphics, fsys fs.FS, name string) (*gfx.Model, error)

Model reads a .gltf or .glb file and uploads it. External buffers and images are read through the same FS relative to the model's directory.

Music source

func Music(m *audio.Mixer, fsys fs.FS, name string, loop bool) (*audio.Music, error)

Music reads a WAV, Ogg Vorbis or MP3 file and opens it for streaming. The encoded bytes stay in memory; decoding happens as it plays.

Pack source

func Pack(dir, out string) error

Pack writes every file under dir into a pack file at out. Files that are already compressed (images, audio, video) are stored as they are; the rest are deflated.

Prefab source

func Prefab(fsys fs.FS, name string) (*ecs.Prefab, error)

Prefab reads a prefab document, for the ecs.PrefabLibrary a scene's prefab references are resolved against. Every component type the file names must be registered first.

SDFFont source

func SDFFont(g *gfx.Graphics, fsys fs.FS, name string, size float32, opts gfx.FontOptions) (*gfx.Font, error)

SDFFont reads a TTF or OTF file and prepares a scalable font.

Scene source

func Scene(fsys fs.FS, name string) (*ecs.Scene, error)

Scene reads a scene document, ready for ecs.World.Instantiate. The component types the scene names are checked when it is instantiated, not here, so a scene loads before the game registers them.

Sound source

func Sound(m *audio.Mixer, fsys fs.FS, name string) (*audio.Sound, error)

Sound reads and decodes a WAV, Ogg Vorbis or MP3 clip into the mixer's format.

Texture source

func Texture(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.TextureOptions) (*gfx.Texture, error)

Texture decodes an image and uploads it. A name ending in .ktx2 is a compressed texture from bunyip-tex: its blocks and its mip levels go to the GPU as they stand, without being decoded or downsampled first.

Tracker source

func Tracker(fsys fs.FS, name string) (*tracker.Module, error)

Tracker reads and parses a MOD, S3M, XM or IT module.

Types

type FS source

type FS struct {
	// contains filtered or unexported fields
}

FS resolves names against its sources in order and implements fs.FS and fs.ReadFileFS. Open and ReadFile use standard io/fs paths. Read, Exists, Path and List retain their cleaned-name convenience syntax.

Open source

func Open(sources ...string) (*FS, error)

Open takes directories and pack files, searched in the order given. A missing source is an error. Check optional source paths with os.Stat before opening; FS.Exists checks asset names after an FS is open. OpenFS accepts embedded file systems as well.

OpenFS source

func OpenFS(sources ...Source) (*FS, error)

OpenFS takes sources of any kind, searched in the order given. A missing directory or pack is an error.

Close source

func (f *FS) Close()

Close releases pack files.

Exists source

func (f *FS) Exists(name string) bool

Exists reports whether some source holds the name.

List source

func (f *FS) List(prefix string) []string

List returns visible file names under prefix, sorted and without duplicates. Unreadable directories are omitted; use fs.WalkDir when errors need to be reported.

Open source

func (f *FS) Open(name string) (file fs.File, err error)

Open opens a file or merged directory using io/fs paths. Earlier sources win when names conflict; directories merge their children. A file hides all lower-priority entries beneath its name. Only missing entries fall through to later sources; other errors are returned. The returned handle belongs to the caller and must be closed before FS.

Path source

func (f *FS) Path(name string) string

Path returns the on-disk path of a loose file, or "" when the name resolves to a pack or fs.FS entry or nothing. Watchers use it.

Read source

func (f *FS) Read(name string) ([]byte, error)

Read returns the named file's contents. Names use forward slashes relative to the source roots, like "sprites/hero.png". Unlike ReadFile, this convenience method cleans dot components and backslashes first.

ReadFile source

func (f *FS) ReadFile(name string) ([]byte, error)

ReadFile reads a file using io/fs paths and errors. The result belongs to the caller. Directories cannot be read as files.

type Handle source

type Handle[T any] struct {
	// contains filtered or unexported fields
}

Handle is a pending or finished load.

Get source

func (h *Handle[T]) Get() (T, error)

Get waits for the load and returns its result.

Ready source

func (h *Handle[T]) Ready() bool

Ready reports whether the load has finished, successfully or not.

Value source

func (h *Handle[T]) Value() (v T, err error, ok bool)

Value returns the result without waiting; ok is false until ready.

type Loader source

type Loader struct {
	// contains filtered or unexported fields
}

Loader reads and decodes assets on worker goroutines so a loading screen can keep drawing. Decoding produces CPU-side data (an image, decoded audio, a parsed model); creating GPU resources from it happens on the main thread once a handle is ready. Close finishes accepted loads and joins the workers before returning; the filesystem can then be closed. Wait joins currently submitted work without closing the loader; stop submitting loads before calling Wait.

NewLoader source

func NewLoader(fsys fs.FS, workers int) *Loader

NewLoader starts workers reading from fs; workers of zero means one per CPU.

Close source

func (l *Loader) Close()

Close stops submission and waits for accepted loads and all workers. Repeated and concurrent calls are safe. A blocked reader or decoder must return before Close can finish. Do not call Close from a decoder.

Load source

func (l *Loader) Load[T any](name string, decode func(data []byte) (T, error)) *Handle[T]

Load reads name and decodes it on a worker. Submission can block when the 256-entry queue is full. A load submitted after shutdown starts returns a ready handle with fs.ErrClosed. Decode must not create GPU resources or call Close on its own loader.

Progress source

func (l *Loader) Progress() (done, total int)

Progress reports loads finished and requested so far, for a bar.

Wait source

func (l *Loader) Wait()

Wait blocks until every requested load has finished.

type Reloader source

type Reloader struct {
	// contains filtered or unexported fields
}

Reloader keeps what a game loaded in step with the files it came from, so a texture repainted or a shader recompiled while the game runs appears the moment it is saved. To use one, load through it instead of the package's loaders and call Reload once a frame:

rel := asset.NewReloader(ctx.Gfx, fs, 0)
tex, err := rel.Texture("sprites/hero.png", gfx.TextureOptions{})
// ... hand tex to a material or draw it as a sprite ...

func (g *game) Update(ctx *engine.Context) error {
	names, err := g.rel.Reload()
	if err != nil {
		ctx.Log.Warn("reload failed", "err", err)
	}
	for _, n := range names {
		ctx.Log.Info("reloaded", "asset", n)
	}
	return nil
}

Everything it loads keeps the pointer it handed back. A texture's image is swapped in place, so every material, sprite and shader slot that names it draws the new pixels without being told, even at a new size; a shader's pipelines are rebuilt, so every draw through it runs the new program. Nothing a game holds goes stale, so a reload needs no bookkeeping of its own.

Only loose files change: a name that resolves into a pack file or an embedded file system is loaded once and never watched, so a shipped game pays for the polling goroutine and nothing else. Close stops it.

Models and environments are not reloaded. Swapping a glTF file's contents would give back different meshes, a different skeleton and different animation clips, which every AnimPlayer, mesh pointer and node index the game holds refers to. A gfx.Environment is built by prefiltering a panorama into a cube map, and a reflection probe bakes and owns one of its own, so replacing the image behind one would mean rebuilding every level of that cube while the game runs. A game that wants either loads it again and rebinds what pointed at the old one.

NewReloader source

func NewReloader(g *gfx.Graphics, fs *FS, interval time.Duration) *Reloader

NewReloader watches fs for changes and reloads through g. interval is how often loose files are checked; zero means half a second. Close it when the game ends.

Close source

func (r *Reloader) Close()

Close stops watching. The assets it loaded are the game's to destroy.

MeshShader source

func (r *Reloader) MeshShader(name string) (*gfx.Shader, error)

MeshShader is Shader for a shader that draws meshes rather than sprites.

Reload source

func (r *Reloader) Reload() ([]string, error)

Reload swaps in every watched file that changed since the last call and returns the names it reloaded. Call it once a frame from Update. A file that fails to read or decode keeps the asset the game already has and its error is returned, while the other names still reload, so one bad save does not stop the rest; the file is tried again the next time it is written.

Shader source

func (r *Reloader) Shader(name string) (*gfx.Shader, error)

Shader compiles a sprite shader from a .spv file written by bunyip-shader and rebuilds it when the file changes. Its images and uniforms are kept across a reload.

Texture source

func (r *Reloader) Texture(name string, opts gfx.TextureOptions) (*gfx.Texture, error)

Texture loads an image or a KTX2 texture and reloads it in place when the file changes. It is asset.Texture with the watching added, so the options and the errors are the same.

Watch source

func (r *Reloader) Watch(name string, reload func(data []byte) error)

Watch calls reload with the file's new contents whenever the named file changes, for an asset this package has no loader for: a level, a table of tuning values, a palette. The call happens inside Reload, on the goroutine that calls it, so it may touch the game and the GPU. Several targets may watch one name and each is called in turn.

type Source source

type Source struct {
	// contains filtered or unexported fields
}

Source is a place an FS looks for files. Build one with Dir, PackFile or FSSource and pass it to OpenFS.

Dir source

func Dir(path string) Source

Dir is a directory of loose files.

FSSource source

func FSSource(fsys fs.FS) Source

FSSource is any io/fs.FS, typically an embed.FS so a game ships its assets inside the binary. Names resolve relative to the FS root, so pass fs.Sub(embedded, "assets") when the embed directive names a directory. Path returns "" for its files and a Watcher ignores them.

Example
package main

import (
	"fmt"
	"os"
	"testing/fstest"

	"github.com/matjam/bunyip/asset"
)

func main() {
	// A game embeds its assets with go:embed and opens them behind an
	// optional loose directory so edits on disk win while developing.
	// Any io/fs.FS works; a MapFS stands in for the embed.FS here.
	embedded := fstest.MapFS{
		"text/intro.txt": {Data: []byte("Once upon a time")},
		"text/end.txt":   {Data: []byte("The end")},
	}
	sources := []asset.Source{asset.FSSource(embedded)}
	if _, err := os.Stat("assets"); err == nil {
		sources = append([]asset.Source{asset.Dir("assets")}, sources...)
	}
	fs, err := asset.OpenFS(sources...)
	if err != nil {
		panic(err)
	}
	defer fs.Close()
	data, _ := fs.Read("text/end.txt")
	fmt.Println(string(data))
	fmt.Println(fs.List("text"))
	fmt.Printf("%q\n", fs.Path("text/end.txt"))
}
Output
The end
[text/end.txt text/intro.txt]
""

PackFile source

func PackFile(path string) Source

PackFile is a pack written by Pack or bunyip-pack.

type Watcher source

type Watcher struct {
	// contains filtered or unexported fields
}

Watcher polls loose files for changes so a running game can reload a texture or shader the moment it is saved. Packed files never change, so names that resolve into a pack are ignored.

Add resolves each name to its file on disk once. A poll then stats that file, plus each directory a copy of the name could appear in ahead of it: the name's directory in every loose source up to the one that holds it, or in every loose source when none does. A name is resolved again only when one of those directories changes, so a copy added to or removed from an overlaying source is still noticed. The poll runs without the lock Changed takes, so a game calling Changed every frame never waits for one.

NewWatcher source

func NewWatcher(fs *FS, interval time.Duration) *Watcher

NewWatcher polls every interval (zero means half a second).

Add source

func (w *Watcher) Add(names ...string)

Add starts watching names; loading an asset and adding it here is the usual pair.

Changed source

func (w *Watcher) Changed() []string

Changed returns the names modified since the last call, in the order they were first noticed, and clears them. A name appears once however many times its file changed, because a save can change a file more than once (truncate, write, set the time) and one reload of the latest contents covers them all. Call it once per frame.

Close source

func (w *Watcher) Close()

Close stops polling.

Source files

asset.go asset_test.go atlas_test.go emitter_test.go example_test.go fs.go fs_test.go load.go load_bench_test.go load_test.go loader.go loader_close_test.go reload.go reload_test.go watch.go watch_test.go