Step 4 - Load assets

Overview

Up to this point within the tutorial we have included players, PhysX, and NPCs. Now we will explain how to setup and configure your game demo to download assets, add terrain to the PhysX scene, and handle initialisation of the terrain. We’ll also give an example of how to cook the terrain data, and host the assets on a local HTTP server for local tests.

One worker per machine will perform an HTTP request, minimising bandwidth and latency. More details on the asset loader can be found in the documentation.

Known issue: as it stands more than one worker per machine will attempt to download the assets. We are aware of the issue and are working on a fix for a later release.

By the end of this step you should be ready to dive into the netcode and net relevancy for optimisation of bandwidth.

Setup

Before we start let's see how the game world looks up to this point.

Get the Source

Navigate to the AetherHunt2 demo folder

There should be a local Git repo in there. All the steps of this tutorial are included as tags on the master branch of this repo.

Check out the code for this step by running the following command git checkout step-4. This will leave the code as it was at the end of the previous step. If you wish to check out the completed state of this section run git checkout step-4-complete. Run git diff step-4 step-4-complete to inspect the diff.

This step requires the asset server to be properly configured. See Getting started for more information.

Run the Simulation

Open PowerShell inside the simulation folder and run aether build and aether run commands

  1. Build the project with cmake -B build -G Ninja; ninja -C ./build

  1. Run the simulation with aether run

Once the simulation is running the client need to be built:

  1. To run the client, open Aether_Hunt.uproject

    1. If you wish to debug the unreal client you can open the Visual Studio solution file and run debug mode from there. Caution this takes more resources

  2. Click build to build the project

  3. Click play to start the client

You will then enter the world as a player.

Look, the orcs are in the sky!

This is because the terrain has been loaded into the client but not into the simulation or parsed into the PhysX scene.

Exit the game and stop the simulation:

  1. Hit \<esc> to exit the game client

  2. Back on the PowerShell where the simulation is running

    1. Press Ctrl+c

    2. Press N to stop the Simulate simulation

How-To Guidelines

Downloading the Terrain on the Simulation

An http request will be made by a worker of the simulation to fetch the asset and allowing it to be used in the simulations.

cell_data.hh

The first thing we need to do is add the include of blob_store.hh. This contains Aether Engine’s asset loading library which facilitates downloading and distributing data from external endpoints for use on the workers. For more information see the technical documentation.

We then add the necessary members:

  • download_terrain and terrain_blob are what we use to initiate an asset download and store it in shared memory.

  • The mesh pointer physx::PxTriangleMesh* is where we will store the parsed PhysX terrain.

Releasing the mesh in the destructor contained within struct cell_data. This is important to avoid a memory leak.

#pragma once
#include <aether/physx/physx.hh>

#include <aether/blob-store/blob_store.hh>
...
struct cell_data : public physx::PxSimulationEventCallback {
+   std::future<aether::blob> download_terrain;
+   physx::PxTriangleMesh* mesh = nullptr;
+   aether::blob terrain_blob;
    ...
    virtual ~cell_data() {
+       if (mesh) {
+           mesh->release();
+       }
    }
...

cell_data.cc

We fill in the constructor for the blob store std::future. Here we’re making use of the user args again to make it easy to replace these values without rebuilding later. We need to define the address, port and parameters for the terrain download. These parameters can be specified here or passed as user arguments by using the user config file.

cell_data::cell_data(const std::vector<std::array<std::string,2>>& user_args)
+   : download_terrain(aether::blob_store<aether::http_store>::get_blob(
+       aether::http_store(user_arg_or_default(user_args, "TERRAIN_ADDRESS", std::string("127.0.0.1")),
+                          user_arg_or_default(user_args, "TERRAIN_PORT", std::string("8000")),
+                          user_arg_or_default(user_args, "TERRAIN_PATH", std::string("")),
+                          user_arg_or_default(user_args, "TERRAIN_SSL", 0)),
+                          user_arg_or_default(user_args, "TERRAIN_FILENAME", std::string("terrain.obj.cooked"))))
    , user_args(user_args) {
    physics = std::make_unique<aether::physx::physx_state>(aether::physx::physx_state::basic_scene(this), false);
}

Once we have setup the download of the asset into the simulation we have to add the code to use it.

In cell_data::initializing_tick() a timeout is kept. This is important in order to get relevant output in case of download failure. This checks every tick whether the download is complete.

 void cell_data::initializing_tick() {
-    initialize();
-    initialized = true;
-    AETHER_LOG(DEBUG)("Scene initialized.");
+    if (!initialized){
+        auto TICK_RATE = user_arg_or_default("TICK_RATE", 15);
+        auto ASSET_DOWNLOAD_TIMEOUT = user_arg_or_default("ASSET_DOWNLOAD_TIMEOUT", 1.5f);
+        uint64_t max_ticks = (uint64_t) TICK_RATE * ASSET_DOWNLOAD_TIMEOUT;
+        if (max_ticks < download_pending++){
+            AETHER_LOG(ERROR)("asset download took too long");
+            assert(false);
+        }
+    }
...
 }

Adding the Terrain to the PhysX Scenes

cell_data.cc

Still in cell_data::initializing_tick(), we proceed to parse the serialised mesh into PhysX and store in cell_data::mesh.

+    if (download_terrain.valid() && download_terrain.wait_for(std::chrono::nanoseconds(0)) == std::future_status::ready) {
+        terrain_blob = download_terrain.get();
+        std::string err;
+        if (!terrain_blob.is_valid(&err)) {
+            AETHER_LOG(ERROR)(fmt::format("Error downloading terrain: {}.", err));
+            assert(false);
+        } else {
+            AETHER_LOG(DEBUG)(fmt::format("took {} ticks to download terrain", download_pending));
+            auto span = terrain_blob.get();
+            PxDefaultMemoryInputData stream{reinterpret_cast<unsigned char*>(const_cast<char*>(span.data())), static_cast<uint32_t>(span.size())};
+            mesh = physics->physics->createTriangleMesh(stream);
+            if (!mesh) {
+                AETHER_LOG(ERROR)("Error parsing terrain.");
+                assert(false);
+            }
+            AETHER_LOG(DEBUG)("Asset downloaded.");
+        }
+
+        download_terrain={};
+        initialize();
+        initialized = true;
+        AETHER_LOG(DEBUG)("Scene initialized.");
+    }
}
...

Turning now to initialise_scene(), we insert the terrain mesh into the PhysX scene.

We already have all our user args, TERRAIN_*.

  • TERRAIN_STATIC_FRICTION - the coefficient of static friction affects how much friction is applied between two surface contact points when they have zero relative velocity

  • TERRAIN_DYNAMIC_FRICTION - the coefficient of dynamic friction affects how much friction is applied between two surface contact points when there is a non-zero relative velocity

  • TERRAIN_RESTITUTION - the coefficient of restitution affects how much kinetic energy is lost or gained when two objects collide

  • TERRAIN_SAFETY_NET_Z - the height of the flat plane

We just need to move the plane down out the way down to z=-300. This will stop the simulation crashing if a capsule somehow finds itself falling underneath the terrain and it tries to fall to negative infinity under gravity.

void cell_data::initialize_scene() {
    ...
-   const auto TERRAIN_SAFETY_NET_Z = user_arg_or_default("TERRAIN_SAFETY_NET_Z", 0.f);
+   const auto TERRAIN_SAFETY_NET_Z = user_arg_or_default("TERRAIN_SAFETY_NET_Z", -300.f);

Further down we create a rigid static body, rotate it, and add it to the scene.

auto material = physx_state->physics->createMaterial(TERRAIN_STATIC_FRICTION, TERRAIN_DYNAMIC_FRICTION, TERRAIN_RESTITUTION);
+   PxRigidStatic* actor = physx_state->physics->createRigidStatic(PxTransform{ PxVec3(0,0,0)});
+   PxTriangleMeshGeometry geom{ mesh, PxMeshScale{1}};
+   PxShape* shape = PxRigidActorExt::createExclusiveShape(*actor, geom, *material);
+   const auto rotateX90 = PxTransform(PxQuat(PxHalfPi, PxVec3(1,0,0)));
+   shape->setLocalPose(rotateX90);
+   physx_state->scene->addActor(*actor);

    float groundLevel = TERRAIN_SAFETY_NET_Z;
    PxRigidStatic* groundPlane = PxCreatePlane(*physx_state->physics, PxPlane(PxVec3(0,0,groundLevel), PxVec3(1,0,groundLevel), PxVec3(0,1,groundLevel)) , *material);
    physx_state->scene->addActor(*groundPlane);

Streaming and Initialising Cells

simulate.cc

It is important to understand how the user_cell_stat_impl::cell_tick() behaves.

The cell tick won’t proceed until the terrain is downloaded. This is by design within this tutorial, as we do not need to worry about long download times given the assets are not large. It also ensures the terrain is downloaded before we process any PhysX. No action is required but it is important to understand the behaviour.

It’s up to the developer to decide how the simulation behaves whilst downloads are pending - in our case there is nothing meaningful to process without the terrain, but it could be that you proceed with partially complete game logic as things stream in. For example, if you were streaming in a city you may progressively refine the detail of the mesh, starting with a boxy world, and loading more fine detail over time. This logic is built within the user_cell_state_impl::cell_tick where we simply skip processing the tick if our assets are not yet downloaded.

void user_cell_state_impl::cell_tick(const aether_cell_state &aether_state, float delta_time) {
    const_tick_delta = delta_time;
+   if (!store.user_data.initialized) {
+       store.user_data.initializing_tick();
+   }
+   if (store.user_data.initialized) {
+       store.tick(aether_state, delta_time);
+   }
}

world.hh The raycast added in a previous step will spawn NPCs nicely near the surface of the terrain.

    template<typename ECS>
    static void spawn_orc(ECS &state, aether::vec2f position) {
        ...
+       bool status = physx_state->scene->raycast(physx_pos, down, maxDistance, hit);
    ...
    }

We have now completed setting up how to download and load the terrain into the game simulation. In previous steps we have included players and physics, but hadn’t imported any terrain. Now that the terrain is included we should be able to run and build our simulation.

Baking Assets

We include an example program for baking an obj mesh file into a serialised PhysX vertex mesh. This can be found in AssetBaker/. There is, however, a pre-baked mesh included terrain.obj.cooked in the assets directory, so that step can be skipped if you don’t intend to edit the terrain.

For the simulation to be able to download the terrain we need to pass the remote server address to which the simulation will connect to for that purpose. Inside simulation directory exists a file named user.cfg that contains assets for the simulation. We need to set TERRAIN_ADDRESS=runtimeassetsaether.z33.web.core.windows.net \ for the download to be possible.

user.cfg

This is a example of a config file

user_bin_args_user_defined = """
GLOBAL_SCALE=0.01 \
PNS_SQRT=1.0 \
MAX_PLAYERS=20 \
ORC_NUMBER=1500 \
CELL_LEVEL=10 \
TICK_RATE=15  \
WORKERS=50 \
WORLD_RADIUS=21100.0 \
SEPERATION=2.8 \
ORC_MAX_SPEED=320.0 \
ORC_MIN_SPEED=290.0 \
EDGE_REPEL=1.9 \
SIGHT=100.0 \
FLOCKING_POWER=739.9 \
FLOCKING_REPEL=4.3 \
DANGER_SIGHT=900.0 \
DANGER_REPEL=70000.0 \
PROJECTILE_HANDOVER_RADIUS_RATIO=1.5 \
PVD_ENABLED=0 \
TERRAIN_ADDRESS=runtimeassetsaether.z33.web.core.windows.net \
"""
log_level = "info"

If you want to see the terrain in the PhysX visual debugger set PVD_ENABLED to 1.

Build and Run the simulation

Now that we’ve made the changes, let’s build and run the simulation again and check the results.

  1. Build the project with cmake -B build -G Ninja; ninja -C ./build

  1. Run the simulation with aether run

And the same with the client:

  1. To run the client, open the Aether_Hunt.uproject

    1. If you wish to debug the unreal client you can open the Visual Studio solution file and run debug mode from there. Caution this takes more resources

  2. Click build to build the project

  3. Click play to start the client

You should now see all of the orcs running around on top of the loaded terrain.

Below is how the NPCs and PhysX is viewed within the PhysX visual debugger.

You are now ready to move on to Step 5: Netcode

Summary

In this section we were able to download and distribute a raw asset containing our 3D terrain collision mesh, and then parse and add it to the PhysX scene in each cell. The NPCs track the terrain and the work is load balanced spatially across cells using Simulate's quadtree.

If you want to checkout the source code state as it should be at this point, run git checkout step-4-complete.

Next, is our final step, where we take a look at the netcode which runs on the array of Connect nodes that exist between the simulation and game-clients. We’ll be able to visualise Simulate's quadtree and also dynamically configure net relevancy by sending messages from the client for debug visualisation purposes.

Last updated