Skip to content

Scaling Up: Application Flow

The previous program examples each fit in to one or two short files; real applications are typically bigger. Unlike the previous pages, this tutorial is not about example code; instead it uses flowcharts to help guide you in creating real TinyFFR programs.

The diagrams here show you what gets created when your application starts, how resources come and go while it runs, and how everything is cleaned up at the end. Once you're familiar with this overall flow, you can use it as a map for structuring larger applications. The example application flows at the end of the page show how it stretches to fit a few common kinds of application.

Overview#

Every TinyFFR application has three phases:

flowchart LR
    init["Initialisation<br/>(create everything needed up-front)"]:::step
    loop["Application Loop<br/>(one iteration per frame)"]:::step
    teardown["Teardown<br/>(dispose everything)"]:::step
    init --> loop --> teardown
    loop -- "next frame" --> loop

    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

1. Initialisation#

flowchart TD
    factory["Create the factory"]:::env
    display["Find a display"]:::env
    window["Create a window"]:::env
    textures["Load or create textures"]:::asset
    materials["Create materials"]:::asset
    meshes["Load or create meshes<br/>(or whole bundled assets)"]:::asset
    other["Load fonts, backdrops, etc."]:::asset
    camera["Create a camera<br/>(and camera controller)"]:::world
    scene["Create a scene"]:::world
    objects["Create model instances & lights"]:::world
    add["Add instances & lights to the scene"]:::world
    renderer["Create renderer(s)<br/>(and a compositor, if layering scenes)"]:::render
    loop["Create the application loop"]:::env
    start(["Start the application loop"]):::step

    factory --> display --> window
    factory --> textures --> materials
    factory --> meshes
    factory --> other
    factory --> camera
    factory --> scene
    materials --> objects
    meshes --> objects
    objects --> add
    scene --> add
    window --> renderer
    camera --> renderer
    add --> renderer
    renderer --> loop --> start

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

The factory always comes first: Every other resource is created through one of its builders, and only one factory can exist at a time. After that, the order of initialisation is mostly dictated by which resources use which other resources. For example, a material needs its textures to exist before it can be created; a model instance needs a mesh and a material; and a renderer needs a scene, a camera, and a window (or other render target) to render with.

Initialisation is also the natural place for slow work. Loading and processing asset files can take a noticeable amount of time (see Pre-Baking Assets), so most applications load whatever they'll definitely need before their application loop starts, rather than in the middle of it. Of course, there will often be some assets that must be loaded on demand during runtime; but loading as much as possible on startup is advisable.

2. The Application Loop#

flowchart TD
    iterate["Iterate the application loop<br/>(waits for the next frame; updates input; gives you the frame's delta time)"]:::env
    quit{"Quit requested?"}:::step
    input["React to user input"]:::step
    churn["Create, add, remove, or dispose resources as needed"]:::world
    update["Update objects<br/>(move, rotate, animate, progress camera controllers)"]:::world
    render["Render the frame"]:::render
    teardown(["Teardown"]):::step

    iterate --> quit
    quit -- "yes" --> teardown
    quit -- "no" --> input --> churn --> update --> render --> iterate

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

Each iteration of the loop produces one frame. Iterating the application loop waits until it's time for the next frame, updates the state of every input device, and tells you how much time has passed since the previous frame (the delta time, used to keep movement and animation independent of the framerate). Your application then reacts to the user's input, makes whatever changes it needs to the world, and finally renders the frame.

The loop ends when the user asks to quit (e.g. by closing the window), or whenever your application decides to stop.

Asynchronous Loads Finish During Iteration

Asynchronous Loading moves a lot of the work of asset loading on to background threads, but each load's final steps (such as creating the asset's GPU resources) must be performed on your application's main thread. TinyFFR performs these steps each time the application loop is iterated, for up to a limited amount of time per frame:

flowchart TD
    start["Start an asynchronous load"]:::asset
    background["Background threads:<br/>read & process the file"]:::asset
    queue["Final steps queued for the main thread"]:::asset
    iterate["Next application loop iteration:<br/>queued steps performed, up to a per-frame time budget"]:::env
    more{"All steps done?"}:::step
    ready["Load result available<br/>(fetch it and use the asset)"]:::asset

    start --> background --> queue --> iterate --> more
    more -- "no; continue next frame" --> iterate
    more -- "yes" --> ready

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

Meanwhile your application keeps running and rendering frames as normal; e.g. to animate a loading screen or keep the game playable. The size of the per-frame time budget trades loading speed against framerate; see Async Loading Time-Sharing.

GPU Memory Is Freed a Few Frames After Disposal

Disposing a resource immediately removes it from your application (it can no longer be used). However, the GPU may still be drawing frames that were submitted before the resource was disposed, so the GPU memory it used is only freed once those frames have finished:

flowchart TD
    dispose["Dispose a resource"]:::asset
    gone["The resource can no longer be used"]:::asset
    inflight["Frames already submitted to the GPU<br/>finish drawing"]:::render
    freed["GPU memory freed<br/>(typically a few frames later)"]:::render

    dispose --> gone --> inflight --> freed

    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px

This rarely matters, but it does mean that GPU memory usage doesn't drop the instant you dispose something. Waiting for the GPU (via the renderer or compositor) lets the memory be freed straight away, which can be worthwhile after disposing a large batch of resources. See Controlling RAM & VRAM Usage.

Runtime Churn#

Most applications can't create everything up-front. Objects appear and disappear while the application runs (e.g. a bullet being fired, an enemy being defeated, or a user opening a new file). The following flowchart shows the usual way to handle adding a new object:

flowchart TD
    need(["Need a new object in the scene"]):::step
    loaded{"Are its mesh & material<br/>already loaded?"}:::step
    pause{"Is a pause acceptable?<br/>(e.g. behind a loading screen)"}:::step
    sync["Load the assets now"]:::asset
    async["Start loading the assets asynchronously<br/>(and keep rendering frames)"]:::asset
    ready["When the load completes..."]:::asset
    instance["Create a model instance<br/>using the loaded mesh & material"]:::world
    add["Add the instance to the scene"]:::world

    need --> loaded
    loaded -- "yes" --> instance
    loaded -- "no" --> pause
    pause -- "yes" --> sync --> instance
    pause -- "no" --> async --> ready --> instance
    instance --> add

    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

And for removing an object:

flowchart TD
    gone(["An object should leave the scene"]):::step
    remove["Remove the instance from the scene"]:::world
    back{"Will it come back soon?"}:::step
    keep["Keep the instance for later<br/>(re-add it when needed)"]:::world
    dispose["Dispose the instance"]:::world
    shared{"Are its mesh & material used<br/>by anything else, or needed again soon?"}:::step
    keepassets["Keep the assets loaded"]:::asset
    disposeassets["Dispose the mesh & material<br/>(then any textures they used)"]:::asset

    gone --> remove --> back
    back -- "yes" --> keep
    back -- "no" --> dispose --> shared
    shared -- "yes" --> keepassets
    shared -- "no" --> disposeassets

    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

A few general principles apply:

  • Create assets once, use them many times. Meshes, materials, and textures are the expensive part of an object (to load, and in memory). Model instances are cheap: A thousand instances of the same mesh and material cost barely more than one. Load each asset once and share it between every instance that uses it.
  • Removing is not disposing. Removing an object from a scene just stops it being drawn; the object still exists and can be added back (or added to a different scene) later. Disposing it destroys it permanently. Something that's only hidden temporarily can simply be removed and re-added. Pooling objects and adding/removing them to a scene as desired is a valid strategy.
  • Swap rather than recreate. Many things can be changed in-place rather than destroyed and rebuilt. An instance's material can be changed, a scene's backdrop can be replaced, lights can be moved and recoloured, and so on.
  • Group resources that live and die together. When a set of resources is always disposed at the same time (e.g. everything belonging to one level), adding them to a resource group as they're created lets you dispose them all at once, in the right order.

3. Teardown#

flowchart TD
    exit(["Application loop ends"]):::step
    loop["Dispose the application loop"]:::env
    renderer["Dispose compositor & renderer(s)"]:::render
    scene["Dispose the scene"]:::world
    objects["Dispose model instances & lights"]:::world
    camera["Dispose camera controller & camera"]:::world
    assets["Dispose assets<br/>(materials before their textures)"]:::asset
    window["Dispose the window"]:::env
    factory["Dispose the factory"]:::env

    exit --> loop --> renderer --> scene --> objects --> camera --> assets --> window --> factory

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

Teardown is initialisation in reverse; each resource is disposed before the resources it uses. The factory is always disposed last.

If you create your resources with C#'s using declarations (as the previous tutorials did), this happens automatically (using declarations are disposed in the reverse order they were declared, at the end of their scope). Note however that as applications grow in size and complexity, relying on using statements tends to become less feasible.

Resources that you create and keep elsewhere (e.g. in fields, collections, or resource groups) need disposing yourself, before the resources they use are disposed.

Dependencies & Disposal Order#

TinyFFR keeps track of which resources use which other resources. A resource that's still in use by another (its dependent) can't be disposed; attempting to do so throws a ResourceDependencyException, and the resource is left as it was. The main dependencies look like this (an arrow points from a resource to the resources it depends on):

flowchart TD
    compositor["Compositor"]:::render
    renderer["Renderer"]:::render
    window["Window"]:::env
    camera["Camera"]:::world
    scene["Scene"]:::world
    instance["Model Instance"]:::world
    light["Light"]:::world
    backdrop["Backdrop Texture"]:::asset
    mesh["Mesh"]:::asset
    material["Material"]:::asset
    texture["Texture"]:::asset

    compositor --> renderer
    compositor --> window
    renderer --> window
    renderer --> camera
    renderer --> scene
    scene --> instance
    scene --> light
    scene --> backdrop
    instance --> mesh
    instance --> material
    material --> texture

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px

The rule is simple: Dispose a resource only once nothing above it in this graph still uses it. Disposing everything in the reverse order it was created always satisfies this rule, as a resource can only ever depend on resources that existed before it.

During the application loop, dependencies are released when the dependent stops using a resource, not only when it's disposed. For example, removing a model instance from a scene releases the scene's dependency on it (so the instance can then be disposed), and changing an instance's material releases its dependency on the old material. The full list of tracked dependencies is on Resource Dependencies.

Example Application Flows#

The following flowcharts show how the same three phases stretch to fit a few common kinds of application.

Game With Levels#

flowchart TD
    init["Initialise shared resources<br/>(window, renderer, scene, camera, UI, player model)"]:::step
    menu["Main menu"]:::step
    loading["Loading screen"]:::step
    loadlevel["Load the level's assets & create its objects<br/>(asynchronously, in to a resource group for the level)"]:::asset
    addlevel["Add the level's objects to the scene"]:::world
    play["Play the level<br/>(application loop)"]:::step
    done{"Level finished?"}:::step
    removelevel["Remove the level's objects from the scene"]:::world
    disposelevel["Dispose the level's resource group"]:::asset
    next{"Another level?"}:::step
    teardown["Teardown shared resources"]:::step

    init --> menu --> loading --> loadlevel --> addlevel --> play --> done
    done -- "not yet" --> play
    done -- "yes" --> removelevel --> disposelevel --> next
    next -- "yes" --> loading
    next -- "no; back to menu" --> menu
    menu -- "quit" --> teardown

    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

Resources that are needed throughout the game (the window, renderer, scene, UI, the player's model, etc.) are created once and live for the whole application. Everything specific to a level is created when the level loads and disposed when it ends. The loading screen is itself just the application loop running as normal while the level loads asynchronously in the background. Pre-Baking Assets can make that loading screen much shorter.

Model Viewer / Editor#

flowchart TD
    init["Initialise<br/>(window, scene, camera & inspector controller, lights, backdrop,<br/>UI overlay, renderers & compositor)"]:::step
    loop["Application loop<br/>(orbit camera, update UI overlay, render)"]:::step
    open{"User opened a file?"}:::step
    load["Load the file asynchronously<br/>(UI overlay shows progress)"]:::asset
    removeold["Remove the previous model from the scene,<br/>then dispose it & its assets"]:::world
    addnew["Create the new model's instances & add them to the scene"]:::world
    frame["Point the camera at the new model"]:::world
    teardown["Teardown"]:::step

    init --> loop --> open
    open -- "no" --> loop
    open -- "yes" --> load --> removeold --> addnew --> frame --> loop
    loop -- "quit" --> teardown

    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

A tool like a model viewer runs for a long time, and most of its resources (the window, scene, camera, lighting, and 2D overlay) live for the whole session. Only the model being viewed changes. Loading the new model asynchronously keeps the interface responsive while a large file loads, and the previous model is only replaced once the new one is ready.

Streaming Open World#

flowchart TD
    init["Initialise<br/>(window, renderer, scene, camera, player, shared materials & textures)"]:::step
    iterate["Application loop iteration"]:::env
    near{"Any nearby areas not yet loaded?"}:::step
    startload["Start loading them asynchronously"]:::asset
    completed{"Any area loads completed?"}:::step
    addarea["Create the area's objects & add them to the scene"]:::world
    far{"Any loaded areas now far away?"}:::step
    removearea["Remove the area's objects from the scene<br/>& dispose the area's resource group"]:::world
    render["Update & render the frame"]:::render
    teardown["Teardown"]:::step

    init --> iterate --> near
    near -- "yes" --> startload --> completed
    near -- "no" --> completed
    completed -- "yes" --> addarea --> far
    completed -- "no" --> far
    far -- "yes" --> removearea --> render
    far -- "no" --> render
    render --> iterate
    iterate -- "quit" --> teardown

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef asset fill:#f4a300,fill-opacity:0.3,stroke:#e09400,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

Here there's no loading screen at all. Instead, areas of the world are loaded in the background as the player approaches them, and disposed once they're far enough away to no longer be needed. Each area's resources are kept in their own resource group so that an area can be unloaded in one step. Because asynchronous loads share each frame's time with rendering, the per-frame budget (see Async Loading Time-Sharing) controls the balance between how quickly areas appear and how smooth the framerate stays. Resources shared by every area (such as common materials and textures) are loaded once at start-up and kept for the whole application.

UI-Framework Hosted Application#

flowchart TD
    startup["Application starts<br/>(UI framework creates its windows & controls)"]:::step
    factory["Create the factory in headless mode"]:::env
    assets["Load assets; create scene, camera & objects"]:::world
    renderer["Create a bindable renderer<br/>& bind it to a TinyFFR scene view control"]:::render
    startloop["Start a UI-framework loop<br/>(with a tick callback)"]:::env
    tick["Each tick: react to input, update objects, render"]:::step
    closing{"Application closing?"}:::step
    stoploop["Stop the loop"]:::env
    unbind["Unbind & dispose the renderer"]:::render
    disposeall["Dispose scene, objects & assets"]:::world
    disposefactory["Dispose the factory"]:::env

    startup --> factory --> assets --> renderer --> startloop --> tick --> closing
    closing -- "no" --> tick
    closing -- "yes" --> stoploop --> unbind --> disposeall --> disposefactory

    classDef env fill:#219ebc,fill-opacity:0.3,stroke:#219ebc,stroke-width:2px
    classDef world fill:#2a9d5c,fill-opacity:0.3,stroke:#2a9d5c,stroke-width:2px
    classDef render fill:#8e5cc4,fill-opacity:0.3,stroke:#8e5cc4,stroke-width:2px
    classDef step fill:#8a939b,fill-opacity:0.2,stroke:#8a939b,stroke-width:2px

When TinyFFR is embedded in a UI framework (such as Avalonia, WPF, or Windows Forms), the framework owns the application's windows and its main loop. Rather than creating a window and iterating an application loop yourself, you place a TinyFFR scene view control in your interface, bind a renderer to it, and start a UI-framework loop that calls your tick callback once per frame. Inside the callback, the flow is the same as any other application loop iteration. The three phases are otherwise unchanged: resources are still created at start-up and disposed (in reverse order) when the application closes.

Some Notes on Resources#

Most things you'll work with in TinyFFR are resources (they also implement the IResource interface).(1)

  1. There are not really any useful public methods on this interface; it is used internally to define some things all resources need to do.

    However, types tagged with IResource can be used in other APIs.

Every resource type in TinyFFR is an opaque handle; i.e. an immutable struct that represents but does not actually contain the resource data. For example, a Camera instance does not actually contain any mutable state or camera data, it only ultimately wraps a pointer to the camera data and a reference to the interface that provides the implementation for that pointer.

In other words, resource types contain just two fields internally:

  1. A pointer/handle;
  2. A reference to the implementation for operations using that pointer/handle.
Why are resources designed as opaque handles?

Although there are many valid possibilities for the design of resource management in a library like TinyFFR, this design was chosen for the following reasons:

  • Resource types remain small, and are quick to pass around between methods or hold in data structures. They can be packed tightly in collections, and iterated over quickly.

  • Cache locality for the actual data is improved. We can store the pointed-to data in whatever structure we want behind the scenes, exposing only pointers or handles in to those data structures.

  • Because resource types are C# structs they do not generate pressure on the GC. Simultaneously, because they are immutable you can not accidentally create copies of them and mutate those copies. You can not accidentally mutate an rvalue(1).

    1. "Rvalues" are values that may not have an actual memory location; i.e. they're transient copies of data.

      For example, when doing something like structInstance.ValueProperty.SomeProperty = someValue; in C# the write to SomeProperty is often lost because the struct returned via ValueProperty is an rvalue and only exists for the lifetime of the expression.

But resources behave like mutable objects?

Resource types give the illusion of being mutable reference-type instances by providing you with properties that actually defer to the enclosed implementation reference. For example, when writing myCamera.Position = newPosition, you're actually invoking the following property:

public Location Position {
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    get => Implementation.GetPosition(_handle);
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    set => Implementation.SetPosition(_handle, value);
}

Implementation is the wrapped implementation reference; and it is passed the camera _handle along with the new value you wish to set. That implementation then does whatever is necessary to affect the change on the camera data.

Neither Implementation nor _handle are mutable; therefore the resource instance itself is readonly. But it ostensibly behaves like a mutable reference type.

C# Compiler Inconsistency

Unfortunately, when working with resource types in TinyFFR you may encounter a "CS1612" error when attempting to set properties via secondary structs.

For example, the following code will not compile:

readonly struct MyStruct {
    public Camera MainCamera { get; }
}

var s = new MyStruct();
s.MainCamera.Position = Location.Origin; // CS1612 error here

You can fix this by using the Set...() methods supplied on each resource type; paired with each settable property:

readonly struct MyStruct {
    public Camera MainCamera { get; }
}

var s = new MyStruct();
s.MainCamera.SetPosition(Location.Origin); // No more error, works as intended

There is an open proposal to fix this inconsistency: https://github.com/dotnet/csharplang/issues/9174

There are also various discussions on github: https://github.com/dotnet/roslyn/issues/45284, https://github.com/dotnet/csharplang/discussions/2068, https://github.com/dotnet/csharplang/discussions/8364.

Where Next#

The following pages show some real-world showcase applications that demonstrate the principles explained above.

Additionally, each part of this flow is covered in more detail in the reference documentation:

Comments