preface
I recently wrote a game engine in C++ and used it to develop a small mobile game called Hop Out. Let’s take a look at the actual operation:
There was a little video here, but I couldn’t show it directly. I thought it would work in GIF format, but it didn’t. So I have to put it in the attachment. If you are interested, please download it and watch it. The file is less than 4MB.)
Hop Out is a game similar to a retro arcade game, but with a 3D cartoon look. The way to enter the level is to change the color of all the pads, which is very similar to Q*Bert’s game.
Hop Out is still in development, but the game engine part is almost complete, so I thought I’d share some tips on game engine development here.
One of the awkward things about developing a game engine, in my opinion, is that you can build something huge without even knowing it, and then it makes your scalp tingle when you see it, so MY philosophy is to keep things under control, which is explained in three ways:
-
Adopt an iterative approach
-
Think twice before you merge
-
Realize that serialization is a big topic
Adopt an iterative approach
My first piece of advice is to get the program up and running quickly, and then develop iteratively.
If possible, find a sample program and start from there. In my case, download the SDL and then opens the Xcode – iOS/Test/TestiPhoneOS xcodeproj, then run on the iPhone testgles2 sample program. Immediately I had a lovely rotating cube, as shown below.
Then I downloaded a 3D model of Mario that someone else had made. I then wrote an OBJ file loader with a less complicated file format, and modified the sample program to replace the cube with Mario, as shown below. Also, I integrated SDL_image to help load textures.
Then, I implemented the twin-stick control to move Mario, as shown below.
Next I wanted to look into bone animation, so I opened Blender and made a tentacle model and manipulated it with a two-bone skeleton that could swing back and forth.
However, instead of using the OBJ file format, I wrote a Python script to export data from Blender to custom JSON files that store skin mesh, bones, animations, etc. I loaded these files into the game with the help of the C++ JSON library.
After the above process was successful, I went on to use Blender to make more elaborate characters. The image below shows the first manipulable 3D character I created.
I did a lot more work, but the point I want to make here is that I didn’t plan the engine architecture before I started programming it. In fact, whenever I add a new feature, I just look to implement it in the simplest code, and then look at the code to see what architecture comes naturally. By engine architecture, I mean the set of modules that make up the game engine, the dependencies between the modules, and the apis that the modules use to interact with each other.
This is an iterative approach, which is very useful when writing a game engine. The advantage is that no matter where you are in development, you always have a running program. If you have a problem extracting a code module later, you can quickly find the error by comparing it to the last code that worked. Obviously, I’m assuming you’re using some kind of source control software.
You might think that this approach to development would waste a lot of time because it would generate a lot of junk code that would need to be cleaned up later. Most of the cleanup, however, involves moving code from one.cpp file to another, extracting function declarations into.h files, or some other simple operation. Deciding where to put your code is a pretty hard job, but obviously it’s a lot easier when the code is in front of you.
And in my opinion, trying to come up with an architecture that you think will meet all of your future needs and then start programming is more time-consuming than iterative development. Here are my two favorite essays on Generalization of overengineering hazards. One is Tomasz Dą Browski’s The Vicious Circle of Generalization, The other is Joel Spolsky’s Don’t Let Architecture Astronauts Scare You.
Note, however, that I’m not saying that you should never solve a problem on paper and then implement it programmatically. I’m not saying you shouldn’t plan out the features you want in advance. For my part, I wanted the game engine to be able to load all assets in a background thread from the beginning, but I didn’t design how to do it, and I didn’t implement it in the first place, in fact I only implemented the ability to load part of assets in the first place.
Think twice before you merge
As programmers, we seem to instinctively avoid code duplication and unify our code styles to make our source code look beautiful and elegant. My second piece of advice, however, is not to blindly follow this instinct.
Give the DRY principle a break
To give you an example, my engine includes several Smart Pointer template classes, similar to STD ::shared_ptr. Each of them can prevent memory leaks by acting as a wrapper around a raw pointer.
-
Owned<> Used for dynamically allocated objects Owned by a single object.
-
Reference<> uses Reference counting so that an object is owned by more than one object.
-
Audio ::AppOwned<> is used by code outside of the audio mixer. It allows the game system to own objects used by the audio mixer, such as the sound currently being played.
-
Audio ::AudioHandle<> uses an audio mixer inside a reference counting system.
It seems as if there is some overlap in the functionality of these classes, violating the DRY(Don’t Repeat Yourself) principle. Indeed, early in development, I tried to reuse as many existing Reference<> classes as possible. But then I discovered that the life cycle of an audio object is governed by some special rules: if the audio object has finished playing and the game doesn’t have a pointer to it, the audio object can be queued up for deletion immediately. If the game has a pointer to the audio object, the audio object should not be deleted. If the game has a pointer to the audio object, but the owner of the pointer is destroyed before the sound finishes playing, then the sound should be cancelled. I think it would be better to introduce a separate template class rather than increase the complexity of Reference<>, which is obviously more practical.
Ninety-five percent of the time, reusing existing code is fine. However, when you feel that reusing code is getting stale, or that you’re making something simple complicated, you should think hard about sticking with it.
Don’t be afraid to use different calling conventions
One thing I don’t like about Java is that every function must be defined in a class. In my opinion, this is a screw-up, and while it may make your code look cleaner, it encourages over-engineering and doesn’t support the iterative approach I mentioned earlier.
In my C++ engine, some functions are classes and some are not. For example, every enemy in the game is a class, and most of the enemy’s behavior is implemented in the class, but the behavior of rolling a sphere is implemented by calling the function sphereCast(), which belongs to the physics namespace, But the function sphereCast() doesn’t belong to any class — it’s part of the Physics module. I organize the code through a build system that manages dependencies between modules. Shoehorning this function into a class doesn’t make much sense to improve code organization.
Let’s talk about dynamic dispatch in polymorphism. We often need to call a function to get an object without knowing its exact type. The first instinct of most C++ programmers is to define abstract base classes using virtual functions and then override those functions in derived classes. It does work, but it’s just one of many ways to do it. There are also dynamic scheduling techniques that introduce no extra code, or have other benefits:
-
C++11 introduced STD ::function, which is a convenient way to store callback functions. You can also write a personal version of STD :: Function that might be less painful to step through in the debugger.
-
Many callback functions can be implemented with a pair of Pointers: a function pointer and an opaque parameter, requiring only an explicit conversion inside the callback function. There are many examples of this in the pure C library.
-
Sometimes the underlying type is actually known at compile time, so you can bind function calls without additional runtime overhead. Turf, a library I use in my game engine, makes heavy use of this technology. Those interested can check out Turf ::Mutex.
-
But sometimes the most straightforward way is to build and maintain a primitive function pointer table yourself. I use this approach in audio mixers and serialization systems. As will be mentioned later, Python interpreters also make extensive use of this technique.
-
You can even store function Pointers in hash tables with function names as keys. I use this technique to schedule input events such as multi-touch events. This is part of a strategy of recording game input and replaying it using a playback system.
Dynamic scheduling is a big topic, and I’m just giving you a few examples, but there are many ways to do it. As you write more extensible underlying code (common in game engine development), you will explore more and more methods. If you’re not used to programming this way, the Python interpreter might be a great resource for you to learn. Written in C, it implements a powerful object model: Each PyObject points to a PyTypeObject, and each PyTypeObject contains a table of function Pointers for dynamic scheduling. If you’re interested, start by reading the document Defining New Types.
Realize that serialization is a big topic
Serialization refers to converting runtime objects into sequences of bytes; in other words, saving and loading data.
For many game engines, game content is created in a variety of editable formats, such as.png,.json,.blend, or some proprietary format, and then converted into platform-specific game formats that the game engine can load quickly. The last application in this pipeline is often called a cooker. Cooker may be integrated into other tools, or even distributed across multiple machines. In general, Cooker and many of the tools are developed and maintained in conjunction with the game engine itself.
In setting up such a pipeline, you set the file format for each stage. You may define some of your own file formats, which may evolve as the engine’s capabilities are added. As they evolve, you may one day find yourself having to make certain programs compatible with previously saved file formats. However, regardless of the format, you will eventually have to serialize in C++.
There are numerous ways to implement serialization in C++, but one obvious way is to add load and save functions to the C++ classes you want to serialize. You can achieve backward compatibility by storing the version number in the header of the file and then passing it to each load function. This works, but it can lead to code that is cumbersome and difficult to maintain.
void load(InStream& in, u32 fileVersion) {
// Load expected member variables
in >> m_position;
in >> m_direction;
// Load a newer variable only if the file version being loaded is 2 or greater
if (fileVersion >= 2) {
in >> m_velocity;
}
}Copy the code
It is possible, however, to write more flexible and less error-prone serialization code, using reflection, specifically to create runtime data that describes the layout of C++ types. For a quick look at how to use reflection in serialization, take a look at the open source project Blender.
A lot of things happen when you build Blender from source code. First, a program called makesDNA is compiled and runs. This program parses a set of C header files in the Blender source tree and outputs a file containing a custom format called SDNA that contains a compact summary of all the C types defined inside these header files. These SDNA data are reflection data. This SDNA data is then linked to Blender and saved with every.blend file Blender writes. From then on, each time a.blend file is loaded, Blender compares the SDNA data from that.blend file to the SDNA data linked to the current version at runtime, using common serialization code to handle the differences. This strategy makes Blender’s forward and backward compatibility very strong. You can load the 1.0 file in the latest version or the new.blend file in the old version.
Similar to Blender, many game engines and related tools generate and use their own reflection data. There are many ways to do this: you can parse your OWN C/C++ source code just like Blender to extract type information. You can also create a separate data description language and write a tool to generate C++ type definitions and reflection data for that language. You can also use preprocessor macros and C++ templates to generate runtime reflection data. Once you have reflected data available, there are countless ways to write a universal serializer based on it.
Obviously, I’ve left out a lot of details here. I just want to make it clear that there are many ways to serialize data, and some of them are quite complex. Programmers generally don’t talk about serialization in the same way they do about other engine systems, despite the fact that most other engine systems rely on serialization. For example, of the 96 programming sessions at GDC 2017, I counted 31 for graphics, 11 for online, 10 for tools, 4 for AI, 3 for physics, and 2 for audio, but only 1 directly involved serialization.
conclusion
Developing a game engine, even on a small scale, is a daunting task. There’s a lot more I could say about this, but given the length of this blog, this is honestly the most practical advice I can think of: iterate, rein in the urge to unify code a little, recognize that serialization is a big issue, and you might be able to determine a more appropriate strategy based on that. In my experience, if you ignore these things, they can get in your way.
How-to – write-your-own-Ccp-game-engine
This article was compiled by Hesir
Please indicate the source of reprint
- Hopoutclip. mp4 (3.66MB, 140 downloads)