Skip to content

Assets

The AssetManager loads and caches textures, sprite atlases, MSDF fonts, JSON, text, and optional FBX model assets. Paths are resolved relative to AppContext::assetRoot() (default assets) through the active IAssetSource.

For a generated game, put content in its top-level assets/ directory and use paths relative to that folder:

MyGame/
  assets/
    sprites/player.png
    audio/jump.wav
    maps/level-one.tmj
auto player = appContext()->assetManager()->loadSprite("sprites/player.png");

Snowpulse packages the same logical paths for desktop, web, iOS, and Android. The provider details below matter when implementing custom streaming or diagnosing platform packaging; ordinary game code can stay with AssetManager.

IAssetStream provides seekable read, seek, tell, and size operations. IAssetSource provides exists, open, readAll, and directory enumeration; access the current provider with AppContext::assetSource(). Desktop, iOS, and web use FileSystemAssetSource. Android uses AndroidAssetSource over AAssetManager, so packaged files are read without extraction or duplicate storage. For portable assets, use UTF-8 paths relative to the active source, forward slashes, and exact filename casing. The built-in sources reject absolute paths, backslashes, drive prefixes, and .. segments; custom sources should enforce an equivalent logical-path boundary.

Textures

Texture2D loads image files into GPU textures. If you call loadSprite() with a name that is not in any atlas, the engine treats it as a standalone texture.

Use TextureLoadOptions when the default decode behavior is not appropriate:

snowpulse::AssetManager::TextureLoadOptions options;
options.srgb = true;
options.premultiplyAlpha = false;
options.flipVerticallyOnLoad = true;

auto* texture = assets->loadTexture("images/surface.png", options);

The defaults are linear color (srgb=false), premultiplied alpha, and vertical flip on load. Options are part of the texture cache key, so the same file may be cached separately with different decode settings.

Sprite Atlases

SpriteAtlas is loaded from JSON. The loader supports: - TexturePacker JSON - Free Texture Packer JSON

Atlas JSON can be either: - frames as an object keyed by sprite name - frames as an array of entries with filename

Use the atlas-qualified loadSprite(atlasPath, spriteName, applySize) overload when sprite names may collide across atlases. The one-path overload first checks sprites from already-loaded atlases, then treats the path as a standalone texture:

auto* atlas = assets->loadAtlas("atlases/interface.json");
auto icon = assets->loadSprite(
    "atlases/interface.json", "icon_confirm.png", false);
auto standalone = assets->loadSprite("images/standalone.png");

Fonts

Font loads MSDF font data using JSON metadata and a PNG atlas. The engine supports the common JSON layout produced by MSDF generators (including the bundled msdf-atlas-gen tools).

JSON

loadJson() parses JSON files into nlohmann::json.

Tiled maps are typically loaded through TiledRenderer, which internally uses the Tiled loader/runtime:

auto* node = scene()->createNode("map");
auto* tiled = node->addComponent<snowpulse::TiledRenderer>();
tiled->load("maps/layout.tmj");
scene()->rootNode()->addChild(node);

Text

loadText() loads any text asset as a raw std::string. Use it for data files that are not JSON, such as dictionaries, message tables, or custom config formats. loadJsonText() remains available as a compatibility wrapper for existing JSON workflows.

Bytes, Streams, and Asset Sources

Use loadBytes() when the complete binary asset should be held in memory. Use openAsset() for seekable access without requiring callers to know whether the active source is a directory, application bundle, or packaged archive. Missing and empty assets both produce an empty vector/string from the whole-file helpers, while openAsset() returns nullptr when it cannot open the path.

const std::vector<std::uint8_t> bytes = assets->loadBytes("data/payload.bin");

if (auto stream = assets->openAsset("data/records.bin")) {
    std::array<std::uint8_t, 16> header {};
    const std::size_t readCount = stream->read(header.data(), header.size());
    const std::int64_t cursor = stream->tell();
    const std::int64_t byteCount = stream->size();
    stream->seek(0, snowpulse::AssetSeekOrigin::Begin);
}

Install sources through AppContext so the asset and audio managers receive the same provider. Passing nullptr restores a FileSystemAssetSource rooted at the current assetRoot(). A custom provider implements exists(), open(), and list(); readAll() is inherited and reads through the returned stream. This example replaces the default provider with another filesystem-backed source and enumerates one logical directory:

auto source = std::make_shared<snowpulse::FileSystemAssetSource>(
    "alternate-assets");
context()->assetSource(source);

const bool hasConfig = source->exists("config/defaults.json");
const std::vector<std::string> entries = source->list("config");
const std::vector<std::uint8_t> configBytes = source->readAll(
    "config/defaults.json");

FBX Models

loadFbx() imports static/skinned model geometry, materials, embedded or referenced textures, skeleton data, and animation clips when the target uses an Assimp-enabled engine variant:

snowpulse_target_link_engine(sampleapp ASSIMP ON SPINE_VERSION "4.2")
auto* model = assets->loadFbx("models/character.fbx");

When SNOWPULSE_HAS_ASSIMP=0, FBX loads report an error and return nullptr. Application code normally uses FbxRenderer::load(), which delegates to the asset manager and retains the cached model.

Rooted Loads and Queries

The rooted variants bypass assetRoot for absolute or pre-resolved paths:

  • loadTextureRooted() (with or without TextureLoadOptions)
  • loadAtlasRooted() and loadSpriteRooted()
  • loadFontRooted()
  • loadJsonRooted() and loadJsonTextRooted()
  • loadTextRooted()
  • loadFbxRooted()

spriteNames() returns the cached atlas sprite names in sorted order. clear() releases all asset-manager caches; callers must stop using returned pointers before clearing.

Rooted methods are filesystem-only compatibility APIs. Do not use them for assets packaged in an Android APK/AAB. Normal texture, font, JSON, atlas, particle, audio, Spine, Effekseer, and Assimp loads use the source boundary. Android base-module assets report ready through the asset-pack API; Play Asset Delivery is deferred behind the same provider boundary.

For example, desktop tooling may open an explicitly resolved external file:

#if defined(SNOW_PLATFORM_DESKTOP)
auto* externalTexture = assets->loadTextureRooted(
    "/absolute/path/to/reference.png");
const std::string externalText = assets->loadTextRooted(
    "/absolute/path/to/notes.txt");
#endif

Optional Web Asset Packs

Asset packs let a web build render with a small set of boot assets and make the remaining files available later. They stage asset files only; C++ code and the core Wasm module are not split into packs.

The feature is controlled at build time:

emcmake cmake -S . -B cmake-build-web-lazy \
    -DCMAKE_BUILD_TYPE=MinSizeRel \
    -DSNOWPULSE_WEB_LAZY_ASSET_LOADING=ON

cmake --build cmake-build-web-lazy --target sampleapp -j4

The option defaults to OFF. When it is off, the manifest can remain in the project but Snowpulse uses the original eager packaging and startup behavior. Native builds also compile the same API but report every pack as ready.

Application code may keep calling requestAssetPack() when the option is off. In an eager or native build, every requested name is treated as Ready and its callback is queued for the next engine update; it is not called synchronously inside requestAssetPack(). This lets the same scene flow work in both modes.

CMake options are cached per build directory. Omitting -DSNOWPULSE_WEB_LAZY_ASSET_LOADING defaults to OFF only in a fresh build directory. If that directory was previously configured with ON, explicitly reconfigure it with -DSNOWPULSE_WEB_LAZY_ASSET_LOADING=OFF or use a new build directory.

Create assets/asset-packs.json beside the application's existing asset folders. For the first smoke test, keep everything in boot:

{
  "version": 1,
  "boot": ["**"],
  "packs": {}
}

Enable lazy loading and confirm the application still starts normally. This first manifest does not improve first-frame time, but it verifies that the build flag, manifest location, and packaging pipeline are correct.

Next, replace the broad ** boot rule with explicit groups and move one self-contained folder at a time into a named pack:

{
  "version": 1,
  "boot": [
    "atlases/interface.*",
    "fonts/interface.*",
    "images/boot/**"
  ],
  "packs": {
    "content": ["content/**"],
    "audio": ["audio/**"]
  }
}

Use these rules when deciding where files belong:

  • Put the loading screen and everything used during application or first-scene initialization in boot.
  • Put a self-contained feature in a named pack when application code can wait for its Ready result before opening those files.
  • Every file not matched by boot or a named pack automatically belongs to the reserved game pack. Request it with requestAssetPack("game").
  • boot and game cannot be used as custom pack names.
  • Pack names accept lowercase letters, digits, hyphens, and underscores.
  • Patterns are case-sensitive, relative to the asset root, use /, and support * within one path segment and recursive **.
  • Physical folders and runtime paths do not change. Do not move files merely to put them in a pack.

Patterns may not overlap. When moving audio/** from boot to an audio pack, remove the matching boot pattern in the same change.

Requesting a Pack

Request a pack from the scene that needs it:

assetManager()->requestAssetPack("content", [this](const auto& status) {
    if (status.state == snowpulse::AssetPackState::Ready) {
        _contentAtlas = assetManager()->loadAtlas(
            "content/interface.json");
    }
});

The current scene keeps running while the request is active. A Ready result means the bytes are mounted at their normal /assets/... paths. Texture, audio, font, and Spine decoding still happens through their existing APIs, so only call those APIs after the pack is ready.

The same pattern applies to the automatic game pack:

assetManager()->requestAssetPack("game", [this](const auto& status) {
    if (status.state != snowpulse::AssetPackState::Ready) {
        return;
    }

    auto* deferredAtlas = assetManager()->loadAtlas("atlases/content.json");
    audioManager()->playBgm("audio/ambience.ogg");
});

Poll status when a loading UI needs progress:

const auto status = assetManager()->assetPackStatus("content");
_loadingProgress = status.progress;

if (status.state == snowpulse::AssetPackState::Failed) {
    _loadingError = status.error;
}

Use retryAssetPack() only after displaying or otherwise handling the failure:

assetManager()->retryAssetPack("content", onContentPackFinished);

Duplicate requests made while a pack is loading are coalesced, but applications should normally request each pack once rather than call the method every frame. All registered completion callbacks run from the engine update loop before scene update. If a callback captures a scene or component pointer, that object must remain alive until the callback runs.

Deferred packs are processed one at a time in request order. Request the most important pack first. Packs stay mounted for the rest of the application; unloading is not supported in v1.

Output Behavior

For normal multi-file web applications, each deferred pack is emitted as a separate .data file and is not downloaded before its request. For single-HTML playables, deferred packs stay inside the HTML as inert base64 blocks and begin incremental decoding only after the first rendered frame.

For single-HTML builds, packs improve when bytes are decoded and mounted; they do not make the final HTML smaller or turn its contents into network downloads. The post-build report shows the final HTML size and raw/base64 size of every deferred pack. SNOWPULSE_WEB_UTF8_BINARY_ENCODING still applies only to the core Wasm.

If lazy loading is enabled without a manifest, CMake warns and retains eager packaging. Validate lazy single-HTML artifacts in every target ad-network preview; switching SNOWPULSE_WEB_LAZY_ASSET_LOADING back to OFF is the production fallback for a network that rejects the staged artifact.

Validation and Common Errors

The manifest compiler fails the build for unmatched globs, overlapping assignments, invalid names, missing linked files, or loader-linked assets in incompatible packs. It recognizes TexturePacker meta.image, MSDF font atlas images, Spine atlas page textures, and the automatic Tiled JSON dependency graph. For .tmj, .tsj, .tj, and recognized legacy .json exports, that graph includes external tilesets, object templates, tileset/per-tile images, image-layer images, and transitive references. A linked file may share its owner’s deferred pack or live in boot; it may not live in a different deferred pack. Tiled paths are resolved relative to the declaring file and retain their full directory structure.

The Tiled graph itself is validated for every snowpulse_add_game() target, including eager packages and native builds; the lazy-pack compiler adds the pack-co-location rule. Run the reusable validator manually with python3 snowpulse/tools/tiled_asset_graph.py validate --assets-dir <assets>. Python 3 is therefore a build prerequisite for game targets containing Tiled JSON (including recognized compatibility .json exports).

Keep independently paired assets that application code opens separately, such as a Spine skeleton and atlas or a path stored in a Tiled file property, together with a folder glob. They are not automatic loader dependencies.

Common setup errors:

  • The application loads a deferred file during scene initialization. Keep that file in boot, or move the load call into the pack's Ready callback.
  • An atlas descriptor and its texture are assigned to different packs. Group the complete atlas folder or export separate source atlases.
  • A Tiled map, tileset, template, or referenced image is assigned to a different deferred pack. Put its complete automatic dependency graph in one pack, or put shared sources in boot.
  • A Tiled reference uses an absolute path, a backslash, a URI, or escapes the asset root. Re-export it as a portable /-separated relative path.
  • A glob matches no files because its capitalization is wrong. Asset-pack matching is case-sensitive on every platform.
  • A file is matched by both boot and a named pack. Remove one assignment; overlaps intentionally fail the build.
  • The manifest exists somewhere other than the asset root. It must be exactly <ASSETS_DIR>/asset-packs.json.

Sample Usage

context()->assetRoot("assets");
auto* assets = context()->assetManager();

// Atlas-qualified and cached-atlas sprite lookup.
assets->loadAtlas("atlases/interface.json");
snowpulse::SpriteRegion button = assets->loadSprite(
    "atlases/interface.json", "button_primary.png");
snowpulse::SpriteRegion icon = assets->loadSprite("icon_confirm.png");

// Standalone texture and sprite.
auto* texture = assets->loadTexture("images/background.png");
snowpulse::SpriteRegion logo = assets->loadSprite("branding/logo.png");

// Font, parsed JSON, raw JSON text, arbitrary text, and binary data.
snowpulse::Font* font = assets->loadFont("fonts/interface.json");
auto settings = assets->loadJson("config/settings.json");
std::string rawJson = assets->loadJsonText("config/settings.json");
std::string terms = assets->loadText("data/terms.txt");
std::vector<std::uint8_t> bytes = assets->loadBytes("data/payload.bin");

// Cached atlas names and an optional FBX model.
auto names = assets->spriteNames();
auto* model = assets->loadFbx("models/character.fbx");