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
- Variables
func Aseprite(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.AsepriteOptions, texOpts gfx.TextureOptions) (*gfx.Aseprite, error)func Atlas(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.TextureOptions) (*gfx.Atlas, error)func Emitter(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.TextureOptions) (particle.Emitter, error)func Font(g *gfx.Graphics, fsys fs.FS, name string, size float32, opts gfx.FontOptions) (*gfx.Font, error)func Image(fsys fs.FS, name string) (image.Image, error)func Model(g *gfx.Graphics, fsys fs.FS, name string) (*gfx.Model, error)func Music(m *audio.Mixer, fsys fs.FS, name string, loop bool) (*audio.Music, error)func Pack(dir, out string) errorfunc Prefab(fsys fs.FS, name string) (*ecs.Prefab, error)func SDFFont(g *gfx.Graphics, fsys fs.FS, name string, size float32, opts gfx.FontOptions) (*gfx.Font, error)func Scene(fsys fs.FS, name string) (*ecs.Scene, error)func Sound(m *audio.Mixer, fsys fs.FS, name string) (*audio.Sound, error)func Texture(g *gfx.Graphics, fsys fs.FS, name string, opts gfx.TextureOptions) (*gfx.Texture, error)func Tracker(fsys fs.FS, name string) (*tracker.Module, error)- type FS
func Open(sources ...string) (*FS, error)func OpenFS(sources ...Source) (*FS, error)func (f *FS) Close()func (f *FS) Exists(name string) boolfunc (f *FS) List(prefix string) []stringfunc (f *FS) Open(name string) (file fs.File, err error)func (f *FS) Path(name string) stringfunc (f *FS) Read(name string) ([]byte, error)func (f *FS) ReadFile(name string) ([]byte, error)
- type Handle
- type Loader
- type Reloader
func NewReloader(g *gfx.Graphics, fs *FS, interval time.Duration) *Reloaderfunc (r *Reloader) Close()func (r *Reloader) MeshShader(name string) (*gfx.Shader, error)func (r *Reloader) Reload() ([]string, error)func (r *Reloader) Shader(name string) (*gfx.Shader, error)func (r *Reloader) Texture(name string, opts gfx.TextureOptions) (*gfx.Texture, error)func (r *Reloader) Watch(name string, reload func(data []byte) error)
- type Source
- type Watcher
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)
}
Once upon a time [text/intro.txt] ONCE UPON A TIME
Variables
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.
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.
type Handle source
type Handle[T any] struct {
// contains filtered or unexported fields
}
Handle is a pending or finished load.
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.
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.
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"))
}
The end
[text/end.txt text/intro.txt]
""
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.
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