Advertisement

Simple yet effective game engine architecture patterns?

Started by January 02, 2022 09:04 PM
21 comments, last by Juliean 2 years, 10 months ago

@JoeJ Probably all true, but that doesn't make it any less useful.

I'm not that much concerned about efficiency/performance. Top priority for me has coding productivity and code simplicity/flexibility/cleanliness (which are all just a secondary aspect that pay into productivity, I guess). I love simple, reusable patterns because you can factor them out of application-specific logic so well, optimize them (within the limits of a generic solution), and understand them really well over time. They can give different applications a very consistent high-level structure and functioning principles, even if the applications look very different on the surface.

And to put things into perspective, my primary development ecosystem for games is Kotlin/JVM. So, for example, considerations like CPU cache efficiency are not the reason why I like the ECS pattern. On the JVM, I don't have direct control over such things anyway. I like ECSs purely for its flexibility and ease of use.

wurstbrot said:
Perhaps I should take another, closer look at it

I got into it by this very detailed article on GitHub and it isn't as strange as you might think. The biggest change one has to perform and people mostly have trouble with, is to think in data instead of classes, functions and program flow. Imagine an assembly line where data is moving from one end to the other, this are reactive data streams and now think of robot arms which pick up those moving data if it fits into their pattern, this is how observers are attached to the stream.

wurstbrot said:
And usually, the second step takes a lot longer than the first. Especially because important details of the environment and use cases change all the time

True, I first avoided Tasks in C# because there was a great mistake in the community about async/await being like a zombie infestation which turns all of your code into async. This was simply wrong but is still heavy propagated by some Stackoverflow answers and tutorials. The most time consuming part in using Tasks is to figure out when you should process something in an async fashion and otherwise use traditional program flow. Keeping this in mind, I had to refactor our build tool as build steps could be performed either async or synchronous when they got into the right ProcessorUnit. The build step then needs to await everything to finish, sometimes just running a simple method on another thread an sometimes also clearing used resources after.

Tasks simply are a function executed on another thread and a promise that informs everyone about the current state. Everything else is done by the scheduler, especially when Tasks need to wait on completion of other Tasks. Once you understand the technical details of how everything works in the background, it is easy to use task based approaches and design your software in the right mannor. Game development is one of the areas where a lot of things can happen in parallel along the usual frame processing, the event system processing user input for example or network traffic received but also doing some resource loading.

In the end, the most time consuming job is to profile the game in order to optimize the workload

Advertisement

Juliean said:
As I said, the reflection is completely independant of ECS or what else, I even use it in the OOP-based gameplay-framework that sits on top of the ECS.

I knew you would do better than the ‘bad’ example i tried to make ; )

But out of interest, how are you declaring/handling property-types in your system? Since it sounds like you don't have any type-system attached to it, do you just use immediate-mode gui calls for the different types?

You don't want to see what i'm doing.
Every data member in the configuration can be bool, int, float … float4. I use a union over float[4], and if it's actually just a bool the other memory is wasted.
And i add each data member manually with a code line per member. The code line gives extra info in parameters, e.g. default value; if i want a slider or just a number for GUI; or if the given description string should be used to extract names of a drop down menu instead of a tool tip.
There is nothing elegant or clever to expect.
Usually i do all this ‘declaration’ work in a function called CreateConfig(), which is called once, typically at application startup, e.g. from constructors. At the end of said function it loads itself from file, and if the configuration object is destroyed it saves its settings. This way changes i do on the running app are preserved for the next startup. And if i decide to add new data members, i do so by adding code in CreateConfig(), but the saved file is still compatible to be loaded and will contain the new stuff next time.

The GUI is created by iterating all data members in order, using flags and min/max numbers etc. if it was given on creation. Mostly defaults are fine, so not too much work on tweaking GUI.

Here the creation function for the screenshot i've used above as example.
I could not create this automatically in such compact way, as it's settings affect many underlying systems of the editor. The amount of needed code is pretty much the same that i would need to make GUI elements anyway.
Downside is the extra parameters to achieve certain things like sliders or drop downs. If i need that, i always need to search for examples in older code.
But it's not too messy : )

		void MakeConfig ()
		{
			config = Configuration("EditorApplication", "configs\\", 0, true);//false);//

			config.BeginScope ("Startup:", 0);
				config.SetParamI ("background threads", 15);
				config.SetParamString ("prefabDirectory", "c:\\dev\\data\\prefabs\\");
				config.SetParamString ("load staticMesh", "sponza8k", 0, true);
				config.SetParamString ("load staticMesh", "Armadillo_input", 0, true);
				config.SetParamString ("load staticMesh", "sphere", 0, true);
				config.SetParamString ("load staticMesh", "botijo", 0, true);
				config.SetParamFN ("activeRegion.minmax[0]", std::array<float,3>{-100.f, -100.0f, -100.0f}.data(), 3);
				config.SetParamFN ("activeRegion.minmax[1]", std::array<float,3>{100.f, 100.f, 100.f}.data(), 3);
				
				config.SetParamB ("start background processing at startup", 0);
				config.SetParamB ("procedural fluid scene at startup", 1);
				config.SetParamB ("fluid from file at startup", 1);
				config.SetParamB ("fluid to scene at startup", 0);
				config.SetParamB ("hm from file at startup", 0);

				config.SetParamString ("fluidPreset", "brain mountains");


				config.SetParamB ("open fluidSim.GUI", 1);
				config.SetParamB ("open heightSim.GUI", 0);
				config.SetParamB ("open DebugSurfaceEffects", 0);
				config.SetParamB ("open DebugMeshing", 0);
				config.SetParamB ("open DebugSDF", 0);
			config.EndScope ();

			config.BeginScope ("EditorApplication::MainLoop()", 1);
				config.SetParamB ("do UserInput", 1);
				config.SetParamB ("do TreeView", 1);
				config.SetParamB ("vis editorScene", 1);
				config.BeginScope ("vis volumeScene", 1);
					config.SetParamB ("vis IsoSurf", 0);
					config.SetParamB ("vis Mesh", 0);
					config.SetParamB ("vis Crossfield", 0);
					config.SetParamB ("vis Wavefield", 0);
					config.SetParamB ("vis Remesh", 0);
					config.SetParamB ("vis Stitch", 0);
					config.SetParamI ("CellLevel", 1);
					config.SetParamB ("updateFocus", 1);
					config.SetParamF ("focusRadius", 5.3f);
					config.SetParamF ("focusDist", 2.f);
				config.EndScope ();
			config.EndScope ();

			config.BeginScope ("EditorApplication.editorScene", 1); // own config?
				config.SetParamI ("vis> maxObjects", 30000, 1.0f, 0, 1000000, Configuration::GUI_IS_SLIDER);//scope path?
				config.SetParamB ("vis> content", 0);
				config.SetParamB ("vis> nodes", 0);
				config.SetParamB ("vis> both", 0);
				config.SetParamB ("vis> skipNonLayerNodes", 0);
				config.SetParamB ("vis> content meshes", 0);
				config.SetParamB ("vis> content wireframe", 0);
				config.SetParamB ("vis> node transform", 1);
				config.SetParamB ("vis> node BBox", 1);
				config.SetParamB ("vis> node verbose", 1);
			config.EndScope ();

			config.LoadFromFile("", 0);
		}
		
		
			


JoeJ said:
And i add each data member manually with a code line per member. The code line gives extra info in parameters, e.g. default value; if i want a slider or just a number for GUI; or if the given description string should be used to extract names of a drop down menu instead of a tool tip.

That looks actually a lot what I was doing before I had the type-system setup. Once I had it, I migrated my config to use a template for a type instead of fixed overloads. And now with my whole design figured out, all settings are backed by a field in a class in one way or another so theres no need for anything to be written manually most of the time - but its not far off of what I'd do if that was not the case ?
The only thing that I'd actually change, is make the scoping automatic:

const auto scope = config.BeginScope ("Startup:", 0);
	scope.SetParamI ("background threads", 15);

(Alternatively just use the return-value to automatically end the scope - as long as you are in C++ you should really use RAII to save yourself having to manually match Begin/End-calls :P)

JoeJ said:
Downside is the extra parameters to achieve certain things like sliders or drop downs. If i need that, i always need to search for examples in older code.

Yeah, my own system currently doesn't support sliders eigther, I've rarely seen the need for it (I'd do it with some sort of custom attributes-system if I ever need to). Drop-downs I use exlusively by enums, which has its own set of downsides (mostly that you always have to register the enum even if its only used once, making it harder to define dropdowns that are only used once - but obviously makes it a lot more scalable if things are reusable)

Juliean said:
(Alternatively just use the return-value to automatically end the scope - as long as you are in C++ you should really use RAII to save yourself having to manually match Begin/End-calls :P)

How would you do this? I could imagine like so:

{
	Configuration::HelperObjectToBeginScope temp (config, "Startup:", 0);
	scope.SetParamI ("background threads", 15);
	//as temp lifeteime ends, it closes the scope
}	

But that's not really better, because now i need to remember there is a helper object if i want to make a scope. (maybe a bad argument)
And if i have nested scopes, i need to come up with more variable names for all the helper objects. (and you know naming things is hard)
Is there a better way?

Juliean said:
Once I had it, I migrated my config to use a template for a type instead of fixed overloads.

Sigh, of coarse. I just did not think about using templates. And worse: I did not realize i should have used them, although i use this configuration stuff already for a year i guess.
This happens because i started using templates really late. Thanks for pointing out : )

Juliean said:
Drop-downs I use exlusively by enums

This reminds me it would be nice to get a string from the enums name. Is there eventually some way to get this, similar to how one can get the current line number of the code file?

JoeJ said:
But that's not really better, because now i need to remember there is a helper object if i want to make a scope. (maybe a bad argument) And if i have nested scopes, i need to come up with more variable names for all the helper objects. (and you know naming things is hard) Is there a better way?

The code would look pretty similar to what I posted:

{
	const auto scope = config.BeginScope ("Startup:", 0);
	
	config.SetParamI ("background threads", 15);
}

The BeginScope-function would return the helper-object. You can then apply “[[nodiscard]]” so there is no way to forget it (as the compiler will then warn you if you do not store the return. In the example above I also solved the problem with giving different names - just add bracket around each scope, I also find it a nice way to group things (indentation is also a good way, probably this is a matter of taste).

JoeJ said:
This reminds me it would be nice to get a string from the enums name. Is there eventually some way to get this, similar to how one can get the current line number of the code file?

Not automatically, no. You could use macros to do the stringification, but unfortunately without you will have to write something along the lines of:

core::registerEnum<ITEM_TYPE>(L"ItemType", {
	{ ITEM_TYPE::ITEM, L"Item" },
	{ ITEM_TYPE::WEAPON, L"Weapon" },
	{ ITEM_TYPE::ARMOR, L"Armor" },
	{ ITEM_TYPE::RING, L"Ring" },
	{ ITEM_TYPE::RUNE, L"Rune" }
}, pFilter);

which as I said is a bit cumbersome for when you only want to use the enum once, otherwise the cost of this is not so bad.

Advertisement

Juliean said:
In the example above I also solved the problem with giving different names - just add bracket around each scope

So i could do such nesting:

{
	const auto scope = config.BeginScope ("Startup:", 0);
	{
		const auto scope = config.BeginScope ("blah:", 0);
	}
}

And any compiler is guaranteed to work with this?
I was never sure how compilers treat scoped variables of the same name (maybe MSVC even had / has related bugs), so i decided to avoid this in general.
But in this case it would be really nice.


Only now i see your initial syntax is a better way of implementing the same idea of using a helper object, and all my concerns are avoided. Learned some things. : )

JoeJ said:
And any compiler is guaranteed to work with this?

Yes, variables inside of nested scopes can redefine the same name - that is called “shadowing” and should be guaranteed by the standard. Some static-code analysis tools might warn about it (since it is a bad idea if you do this with variables that you indend to use, as you then could accidentially use the wrong variable. But if you use it for things like scope-guards, mutexes etc… its perfectly valid.

JoeJ said:
I was never sure how compilers treat scoped variables of the same name (maybe MSVC even had / has related bugs), so i decided to avoid this in general.

MSVC does have a ton of bugs, so maybe there was something at one point, but at least not anymore as far as I know :D

JoeJ said:
This reminds me it would be nice to get a string from the enums name. Is there eventually some way to get this, similar to how one can get the current line number of the code file?

Not automatically, no. You could use macros to do the stringification, but unfortunately without you will have to write something along the lines of:

core::registerEnum<ITEM_TYPE>(L"ItemType", {
	{ ITEM_TYPE::ITEM, L"Item" },
	{ ITEM_TYPE::WEAPON, L"Weapon" },
	{ ITEM_TYPE::ARMOR, L"Armor" },
	{ ITEM_TYPE::RING, L"Ring" },
	{ ITEM_TYPE::RUNE, L"Rune" }
}, pFilter);

which as I said is a bit cumbersome for when you only want to use the enum once, otherwise the cost of this is not so bad.

https://github.com/Neargye/magic_enum​ will work in most cases.

@Eternal

From your point of view, is there anything wrong using X macros? I'm starting with C++ and I'm never sure if what I read is the best/good option…
Using the # to stringify the value.

// HEADER FILE
/* https://en.wikipedia.org/wiki/X_Macro */
#define LIST_EVENTTYPE				\
		X(EVENT_NULL)				\
		X(APP_STARTED)				\
		X(EVENT_KEYUP)				\
		X(LAST_ELEMENT)						// needed to avoid knowing the size of the array

enum class EventType : unsigned int			// C++11  specifying  enum's underlying type
{
	#define X(name) name,
	LIST_EVENTTYPE
	#undef X	
};

const char* EventTypeString[];

// CPP FILE
const char* EventTypeString[] = {
	#define X(name) #name,	
	LIST_EVENTTYPE
	#undef X
};

None

This topic is closed to new replies.

Advertisement