I'd like to make this a bit more regular hobby, releasing updates on my own game engine (among other projects), yet sometimes my real work steps in and I tend to be quite busy. Anyways back to the important part and that is what is this article about.
Making a game engine with WYSIWYG editor is quite a challenge, it takes years of practice and learning, any resources are scarce - and therefore you have to improvise a lot. Sometimes it takes dozens of attempts before you got it right, and the feeling when you finally did it right is awesome. This time I'm going to dig a bit into how I designed save, load & publish system, and which parts of it are done.
Introduction
To understand save/load/publish system, one needs to understand how his engine is going to work. In Skye Cuillin (codename for my game engine) you have editor application, which is used to create your game scenes, test them and possibly publish them. On the other hand, the planned runtime application takes in exported scene a plays it.
There are 2 possible ways to save a scene - first is something I call Editable Scene, second thing is what I call Exported Scene. Both file formats serves different purposes and stores all the data in completely different way.
- Editable Scene is saving primarily just a scenegraph, with all entities and their components in a way where editor can easily load them again. It doesn't store any resource files (meshes, textures, etc.) but uses the one on the hard drive. This is mainly to keep the amount of data in the file to minimum (after all it is a text file, not binary file).
- Exported Scene is storing both - scenegraph, with all entities and their components, along with all resource files (meshes, textures, etc.) in a specific compressed and binary encoded format, so it is fast to load, but losing possibility to be loaded back into editor.
I won't get into details of Exported Scene type today, as that part is still in testing - yet I want to show some details on how Editable Scene is working. Further, I will describe and talk only about Editable Scene format.
Saving & File Format
In the engine, the scenegraph is a N-ary tree with a single root node. This node can't be edited in any way and can't have any components except transform component, which is at coordinates of (0, 0, 0). It is intentionally shown also in the scene graph view on the left side in the editor, and user is unable to select it through there. The only action that can be performed on it is adding new Entity as its child (either by assigning Entity's parent as root, or adding new entity into scene under root), or removing Entity as its child (either by re-assignment under different parent Entity, or actual deletion).
Entity class has a Serialize method, which is this as of now:
std::string Entity::Serialize()
{
std::stringstream ss;
ss << "Entity" << std::endl;
// Store the name
ss << mName << std::endl;
// Note. Each entity except Root will always have a parent!
// Store the parent name
if (mParent)
{
ss << mParent->mName << std::endl;
}
else
{
ss << std::endl;
}
// Store the transform
ss << mTransform.Serialize();
// Store components
for (auto it = mObject.ComponentsItBegin(); it != mObject.ComponentsItEnd(); it++)
{
ss << it->second->Serialize();
}
ss << "(" << std::endl;
// Store children (recursively)
if (mChildren.size() > 0)
{
for (auto child : mChildren)
{
ss << child->Serialize();
}
}
ss << ")" << std::endl;
return ss.str();
}
Simply store some keyword, base entity data (name, parents and transformation), component data and then list of children within parentheses. Also note that each component has Serialize method which simply serializes it into text. On side, all this is also being used for Undo/Redo functionality of the engine.
Example of serialized entity (spotlight) can look like this:
Entity
SpotLight
Root
Transformation
-450 90 -40
0 0 0 0
1 1 1
LightComponent
1 1 1 1 1 1 2000 1 0 0.01 256 1 5000 900
-0.565916 -0.16169 0.808452 0 45 0 0
(
)
Another example (with links to resources) is F.e. this cube:
Entity
cube_Cube_Cube.001_0
cube
Transformation
0 0 0
0 0 0 0
1 1 1
MaterialComponent
../Data/Shared/Models/Textures/Default_basecolor.tga
../Data/Shared/Models/Textures/Default_normal.tga
../Data/Shared/Models/Textures/Default_metallic.tga
../Data/Shared/Models/textures/Default_roughness.tga
../Data/Shared/Models/Textures/Default_height.tga
MeshComponent
cube_Cube_Cube.001_0
(
)
The names of resources are simply names used in key-value databases (key is name, value is pointer to resource). All resources in project folder are loaded when starting editor - therefore these reference names will always point to correct resources in key-value database for resources (this is assuming project folder matches the one which should be for scene).
Loading
When saving was done with Serialize method, then it makes sense to implement Deserialize method, which takes text as input and initializes everything for given entity based on it. While it is a bit longer, I'm going to paste here while source of it:
void Entity::Deserialize(Scene* scene, const std::string& s, Entity* parent)
{
std::vector<std::string> lines = String::Split(s, '\n');
// Read name and store it
mName = lines[1];
// We need to prevent creation of entity if it already exists in scene. This is mainly because root entity will be stored,
// while at the same time it also already exists in scene (it always has to exist).
if (scene->GetEntity(mName) == nullptr)
{
// If we didn't specify parent for deserialize through parameter
if (parent == nullptr)
{
// Get entity from next line, locate it in scene and use it
Entity* parsedParent = scene->GetEntity(lines[2]);
parsedParent->AddChild(this);
}
else
{
// Parent is current node (only when passed through parameter - deserialize for some Undo/Redo cases)
parent->AddChild(this);
}
}
// Restore transformation, which is composed of 4 lines:
// 1. keyword - "Transformation"
// 2. translation X Y Z numbers representing 3D position - "0 0 0"
// 3. rotation X Y Z W numbers representing quaternion - "0 0 0 1"
// 4. scale X Y Z numbers - "0 0 0"
// These 4 lines need to be joined, and passed in as a parameter for Deserialize method into Transform object
std::vector<std::string> transformData;
transformData.push_back(lines[3]);
transformData.push_back(lines[4]);
transformData.push_back(lines[5]);
transformData.push_back(lines[6]);
std::string transform = String::Join(transformData, '\n');
mTransform.Deserialize(transform);
// Deserialize each component one by one
unsigned int lineID = 7;
while (true)
{
if (lineID == lines.size())
{
break;
}
if (lines[lineID].size() < 1)
{
lineID++;
continue;
}
if (lines[lineID][0] == '(')
{
break;
}
ComponentId compId = ComponentTypeId::Get(lines[lineID]);
Component* c = ComponentFactory::CreateComponent(compId);
std::vector<std::string> componentData;
componentData.push_back(lines[lineID]);
unsigned int componentEnd = lineID + 1;
while (true)
{
if (componentEnd == lines.size())
{
break;
}
if (lines[componentEnd].size() < 1)
{
componentEnd++;
continue;
}
if (lines[componentEnd][0] == '(')
{
break;
}
if (lines[componentEnd].find("Component") != std::string::npos)
{
break;
}
componentData.push_back(lines[componentEnd]);
componentEnd++;
}
std::string component = String::Join(componentData, '\n');
c->Deserialize(component);
mObject.AddComponent(compId, c);
lineID = componentEnd;
}
// If at this point we're not at the end yet, it means there are children for the node, parsing
// those out is a bit tricky - we need to take whole list of entities within node, each into
// separate string buffer
// Children tree depth search (only level 1 has to be taken into account, all other levels have
// to be included in string buffer for respective entity
int level = 0;
// Should we instantiate entity?
int instEntity = 0;
// String buffer
std::vector<std::string> entityData;
// Note: When deserializing children we can always pass 'this' as parent, simply because due to
// the format, we know the hierarchy of the entities
while (true)
{
if (lineID == lines.size())
{
break;
}
if (lines[lineID].size() < 1)
{
lineID++;
continue;
}
if (lines[lineID][0] == '(')
{
level++;
if (level != 1)
{
entityData.push_back(lines[lineID]);
}
lineID++;
continue;
}
if (level == 0)
{
break;
}
if (lines[lineID][0] == ')')
{
level--;
if (level != 1)
{
entityData.push_back(lines[lineID]);
}
lineID++;
continue;
}
if (level == 1)
{
if (lines[lineID].find("Entity") != std::string::npos)
{
if (instEntity == 1)
{
Entity* e = new Entity("_TempChild");
std::string entityString = String::Join(entityData, '\n');
e->Deserialize(scene, entityString, this);
entityData.clear();
unsigned int id = scene->GetIDGenerator()->Next();
e->mSceneID = id;
scene->GetSearchMap()->Add(id, e->GetName(), e);
}
// Offset line here by 1, in case name contained 'Entity' so we don't double-hit keyword
instEntity = 1;
entityData.push_back(lines[lineID]);
lineID++;
}
// Push line
entityData.push_back(lines[lineID]);
}
else
{
// Push line
entityData.push_back(lines[lineID]);
}
lineID++;
}
// If there is one more, instantiate it
if (instEntity == 1)
{
Entity* e = new Entity("_TempChild");
std::string entityString = String::Join(entityData, '\n');
e->Deserialize(scene, entityString, this);
entityData.clear();
unsigned int id = scene->GetIDGenerator()->Next();
e->mSceneID = id;
scene->GetSearchMap()->Add(id, e->GetName(), e);
}
}
Which will load whole scene, and instantiate it into current scene graph (before loading scene it is cleared so that only root remains).
Showtime
And of course - save/load system has to be showed out!