The rise of simulator games as a genre several years ago showed that any routine can be gamified. The apotheosis was the HYIP around the Goat Simulator, which was honored to be
mentioned in the key presentation of WWDC 2015. We did not stand aside from this trend, having participated in the development of the engine for a series of applications simulating the work of the subway.
Subway Simulator - a series of simulation games subway. The very first version of the game, released in 2014, although it was rather abstract, confirmed the demand for a product of a similar subject, and a rather high one - the project took a leading position in its niche almost immediately after launch. Subsequent updates and new versions of the product were aimed at making the Subway Simulator more realistic: the simulation of trains and stations reached a new level, and also “localized” versions of the game appeared, displaying the subways of
New York ,
Beijing ,
Moscow and other cities. At the moment, the total number of installations of the first version of the game on iOS has almost reached the millionth value. Simultaneously, the game becomes available for other platforms.
When developing an engine based on the simulation of movement in a space of sufficiently large dimensions, it is necessary to take into account the limits of memory for adequate work on the device. In games that require significant resource consumption, optimization becomes the determining factor for the user experience. With its help you can provide a realistic, attractive picture and a smooth gameplay process. This article will focus on our work on the Subway Simulator 3D simulator and various types of optimization that were used to minimize memory consumption without losing quality.
')
Application development was conducted on the most accessible Unity game engine. Considering the fact that periodic updates of the models of new stations to the engine are planned for updates, we stopped at the most trivial, but the only possible solution - the modular core of the game.
The principle of work with him, in fact, is the same as that of the classic runners.
There is a player’s prefab (in this case, the train composition, into which the required model of train is loaded, and other parameters - speed, wear, power, sound, etc.).
There are blocks, or tiles, of objects, each of which has an object for nesting the model, as well as pivots of the zero coordinate of the model and its opposite end, which will automatically calculate the location for the spawn of the next block.
The game features several types of blocks:
The tunnel — linear or twisted — doesn't matter.
The station is a block with its own list of events and cut scenes for the game. Contains passenger prefabs.
Custom event block - may contain a tunnel with a fork, where, say, the train needs an emergency stop, slow down, etc.
The essence of the engine:

As can be seen from the diagram, the main character is the train. Accordingly, in relation to him we are building a path. First of all, the Route Controller is connected with the train, which builds a dynamic route line along which the train moves. The line itself is built on pre-prepared Transform'am in each block, with each Transform located exactly in the middle of the rail track.
The dynamic component of the gameplay is the most resource-intensive; to properly optimize it, we had to apply a variety of solutions. Consider them below.
The implementation of the movement of the train
When creating a train engine, the main question is how much detail the process of movement should be recreated. Here you need to be well aware of the train as a physical body, to understand that this is a multi-ton object that requires gradual acceleration and stopping, that its position and tilt are determined depending on the degree of rotation of the track.
To imitate the movement of a train in full compliance with the laws of physics would be problematic. At a minimum, we would have to take into account the friction of WheelColliders, which in Unity do not always behave adequately, especially if we are talking about more complex and larger wheeled vehicles than just a car. And this is just one of many factors. The most serious obstacle is that such detailed miscalculations of physics would give too much load on the engine. This would have a bad effect on performance, and it would add more bugs.
In our opinion, the optimal way out is to make movement in physics only as an offset in position along a given route.
Rotation of the train in the simulator is calculated based on the difference between the two angles - the current and the previous one. The greater the difference - the stronger the train will lean to the side, which will give the corresponding roll.
private void Change() { target.position = Vector3.Lerp(target.position, currentPoint.position, lerpSpeed * Time.deltaTime);
The train is shaken by animating the cameras (the driver shakes his head at a pace that is determined by the speed of movement) and shakes the animation of the model itself depending on the speed or degree of braking. Cycling animation of a camera or object is quite easy to do with ordinary TweenPosition or TweenTransform, which are standard components in the NGUI engine.
The main thing is that the animation dependence on the speed of the composition is respected. An example of dependence, taking into account the coefficients of the train speed, is given below:
void FixedUpdate() { speed = TrainEngine.Instance.speed; maxSpeed = TrainEngine.Instance.maxSpeed; tweenRot.dutation = (speed / maxSpeed) * 10; tweenRot.from.y = speed / (maxSpeed)/30; tweenRot.to.y = -tweenRot.from.y; }

With physics figured out, move on to the next problem. When creating a block-based engine, you should keep in mind the need to read the distance from the object of the operating unit to the train. Ideally, you need to keep the number of operations to calculate the distance to a minimum.
The logic of the work of our engine implies that at each particular moment the distance is calculated only between the train and the blocks nearest to it. The remaining tunnels and stations are in the pool (Object Pooling) and do not interact with the main controller in any way. As soon as the train has passed the active unit, the last from the active sheet is redirected to the passive one.
lenghtBegin - block length, after which we can remove it and build a path further
ItemType - block type
private void DistanceCheck() { if ((Vector3.Distance(transform.position, player.transform.position) > lenghtBegin)) {
Next, the Route Controller script should work directly with the Way Controller. It is the Way Controller that determines the list of objects that the Route Controller will process and deliver to the train as a target of movement.
The main convenience of the version of the engine that we use is that it is enough to drive in the necessary values ​​in order to change the character of movement on the line at once. For example, to make the spans between stations longer or shorter, if the statistics show that the current duration of the trip does not suit the users. If there are new station models, you can simply add them to the list of engine models, and then set the beginning and end of the Pivot in each.
If the application used static locations - flashing and updating would be a very time consuming and lengthy process. In this version, with a minimum number of actions, we can download a version with a new station or additional models, without using a server. Although, of course, with large volumes of updates, the server remains the only correct solution.
Debugging Metrics
Another traffic issue was the relationship between distance and metric in Unity. If the path of our train will always be generated in blocks continuously forward, sooner or later the object will be in such coordinates that an adequate frame miscalculation will be impossible.
The cabin of the train, consisting of several meshes, began to shake in the literal sense of the word when the coordinates reached too high values.
This is due to the fact that in Unity there is no concept of infinite space, only conditional boundaries, to which you can easily calculate the coordinate values. The farther we go from these boundaries, the more error will be rendering the scene.
Based on this, we decided to supplement the game engine with respawn - in other words, make it so that when arriving at the station, the train returns with it and the nearest rebuilt tunnel blocks to the zero point of coordinates. This decision reduces the likelihood of errors in the miscalculation of the movement, if the player rolls, say, half an hour or more.
public void RespawnStation() { StationResp [0].SetTriggers (false); StationResp [0].transform.localPosition = new Vector3 (0.0f, 0.0f, 0.0f); StationResp [0].transform.localRotation = Quaternion.identity; EndBuild.transform.position = new Vector3(StationResp [0].EndPos.position.x,StationResp [0].EndPos.position.y,StationResp [0].EndPos.position.z); EndBuild.transform.rotation = Quaternion.Euler (StationResp [0].EndPos.eulerAngles.x,StationResp [0].EndPos.eulerAngles.y, StationResp [0].EndPos.eulerAngles.z); Train.transform.localPosition = new Vector3 (RespPoint.localPosition.x, RespPoint.localPosition.y, RespPoint.localPosition.z); Train.transform.localRotation = RespPoint.localRotation; WayController.Instance.RebuildBlocks (); }
The work done with the engine significantly unloaded the memory, but we did not stop there. The next step was to optimize the visual and sound of the application.
Texture compression
The first versions of Subway Simulator included a static location with pre-configured light sources (including in Realtime). It quickly became clear that such a concept only works as long as the game does not use multi-poly models and a large number of textures, both for the interface and for locations, trains and people. Otherwise, it is necessary to choose another option that would provide good quality with moderate memory costs.
First of all, shaders with pre-baked light maps (LightMaps) were written - we wanted to abandon the illumination of lighting in principle, replacing it with special shaders on materials and searchlights on the train.
When texturing was used the method described
here . In our implementation, everything turned out quite simple, but the effect on performance was the most beneficial.
We compressed each of the location textures to 256x256 using RGB Compressed PRVTC 4 bit compression. But LightMaps (lighting maps) were arranged in pairs in one image on separate RGB channels.
Also, a channel mixing shader was added, through which on the necessary material the object will display the map we need of three variations. For clarity, here is a screenshot of a part of the shader code that mixes RGB for textures:
void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; fixed4 light = tex2D (_Light, IN.uv2_Light); fixed4 g = tex2D (_MainTex2, IN.uv_MainTex); fixed a = (c.r+c.g+cb)/3; fixed3 r; rr = ((g.r+(cr-a))*_R)+((g.g+(cr-a))*_G)+((g.b+(cr-a))*_B)+((g.a+(cr-a))*_A); rg = ((g.r+(cg-a))*_R)+((g.g+(cg-a))*_G)+((g.b+(cg-a))*_B)+((g.a+(cg-a))*_A); rb = ((g.r+(cb-a))*_R)+((g.g+(cb-a))*_G)+((g.b+(cb-a))*_B)+((g.a+(cb-a))*_A); o.Albedo = r.rgb; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Emission = ((light.r*_N1)+(light.g*_N2)+(light.b*_N3)+(light.a*_N4))*r.rgb; o.Alpha = ca; }
As a result, the color of the picture itself is compressed, but the alpha channel map, lighting and contrast remain of an acceptable size, so there is almost no deterioration from the screens of mobile devices.


Above is an example of the distribution of textures. The left image stores only the color in itself, we compress it as much as possible (for textures of medium-sized locations, the limit is usually just 256 pixels). The right image keeps the contrast and LightMaps in three RGB channels.
We used the same method of packing textures with trains, because in the application each composition has 4 separate colors in the resolution of 512 by 512. Thus, we managed to reduce memory consumption by both locations and trains - in total, almost doubled.
It is also very useful to perform compression of 2-bit compression for any small location objects. With large objects this solution does not work - visible artifacts may appear.
The screenshot below is an example of an unsuccessful attempt at such compression: the artifacts are clearly visible in the upper part of the location.

Compression of sound and models
Within the framework of additional optimization, we squeezed all sounds to the maximum value - 1. The testing experience showed that there is practically no loss in sound quality from mobile devices. Exposing the Mono-mode of all sound files also reduced the memory consumption by almost one and a half times.
For reference: it is better to use Decompress On Load for long sounds and Compressed In Memory for short ones.

Prototype modeling
The task of a good simulator is not only to interest the user in the detailed mechanics of the gameplay, but also to provide a decent picture. Based on what was decided to simulate several trains and stations with their real copies. Thanks to the sketches and schemes that are in open access, at the moment, it was possible to very realistically implement 3 models of trains, and several stations of the Moscow and Beijing metro.
The average indicators of the polygoning stations are 25-30 thousand polygons and 12-20 thousand polygons near the trains. As we said above, we additionally applied texture compression and a hard limit on the amount of material per object. Since the number of textures and models is quite large, we abandoned the illumination errors, having stopped only on previously prepared shadow maps in the textures.
The result of the simulation can be seen below:


Metro station "Novoslobodskaya" photo
Metro station "Novoslobodskaya", screenshotConclusion
When changing the game engine from static to dynamic maps with loading of individual blocks of location, it was possible to significantly reduce the memory consumption of the application during operation. The application can now run on weaker devices and at the same time produce an acceptable number of frames per second.
Packing textures and atlases into separate spectra of the RGB channel helped to reduce the weight of the application. This is especially important for those cases where there are so many textures in the applications that even the presence of atlases does not save. When developing games of the “simulator” genre, this problem is particularly acute, since the authenticity of the environment requires the maximum number of photographically accurate elements. In our case, thanks to this stage of compression and packing, we managed to keep in a decent quality all the most necessary details in the game.
As for the implementation of mechanics, here, due to the dynamic loading of the location, it became possible to control the process of passing gameplay by the player and the length of the sessions. Based on the data on how many users, on average, play at one level or another, you can identify weak and strong places and expand or trim the gameplay accordingly. Also, thanks to the dynamic engine, it became possible in the future to add new stations to the engine directly from the server, without a full reassembly of the application.
We hope the proposed solutions will be of interest to readers. Thanks for attention!