OVERVIEW

Starfall is a sand-surfing collection game developed using C++ and OpenGL. Race over dunes to collect star fragments that power your escape. Hurry before the moon sets though, or you'll be stuck in dark. As you collect star fragments, drone helpers will join you and cheer you on in your endeavor.

USER GUIDE

LUNA, the main character, is controlled using W/A/S/D and LEFT SHIFT. W - Move Forwards S - Move Backwards A - Turn Left D - Turn Right LEFT SHIFT - Increase downwards force The shift key should be used only when you are sure you are travelling on a downhill slope, otherwise it will slow you down. A tip for new players is to only focus on going straight forward and go to star fragments only a little bit out of reach.

Gameplay Video


Inspiration


Concept Art


Features

Player Movement

Luna moves with the slopes of the terrain! Holding shift launches them down slopes, but beware because that same downward momentum can slow them down while going uphill. Try to pick up speed going down slopes to collect stars as fast as you can.

Terrain

The dunes themselves are procedurally generated and infinite, giving you plenty of space to zoom over the slopes. It also is tessellated allowing for better visuals close to the camera while saving on rendering overhead faraway. Hold z to see the meshes making up all the objects. The shape of the dunes is defined using a clever composite of sine and cosine functions to generate the repeating soft hills. The mesh of the terrain moves and rotates with the player, provided infinite terrain with a still relatively small amount of vertices needed. The dune shape function is evaluated at world space coordinates, meaning that the displacement is stable despite the mesh moving with the player.

Particle Systems

The particle system used initialized several vertex array objects, with a few vertex buffer objects per VAO, with different semi-randomized characteristics. Then, at program setup, all particle data was buffered onto the GPU, and the particle system class kept their references as static class members. A newly-created particle system would select the next vertex array object, and a random starting position into the vertex buffers, in order to get randomized characteristics without buffering data every frame. There was no sorting done on any of the particle systems, instead glDepthMask was set to disabled, and alpha values were adjusted to minimize any potential color saturation caused by stacking.
The particle systems activated upon collecting a star fragment were smaller points, indexed into a buffer that was generated with randomized normals, and used a constant Perlin noise, added to a higher-weighted stacked Perlin noise that adjusted the point's vertex position along the direction of the random normal. This was animated using log(1 + the particle system's total time), and faded out with an alpha value that decreased with total time, to create a starburst effect with a faster particle speed at first that tapered off and faded out. The particle systems activated upon exceeding a certain velocity were done by using a sprite sheet, precomputing the texture-space coordinates of each image in the sprite sheet and storing into a lookup table, and indexing into the sprite sheet table based on the total lifetime of the particle system. A particle system that managed one particle was spawned under the player, with a transform component that locked it at the player's position upon particle system spawn. The alpha values faded in and out over the lifetime of the particle system, as well as with the speed the player was travelling when the system was spawned.

Sound

Sound was all done through Miniaudio's high-level API. All sounds were loaded from files at startup. The soundtrack played at the start of the game, and the sound effects were played upon collecting a star fragment. 100 instances of the sound effect were loaded and iterated through to ensure the sound effects could play simultaneously. The soundtrack's main key was B minor, and a vector of integers representing the half steps of the B natural minor scale was used to adjust the pitch of the base sound effect if additional star fragments were collected in a period of 1s. The formula for adjusting a pitch in Hz up n half steps is pitch' = pitch * 2 ^ (n/12).

View Frustum Culling

View frustum culling was done individually on star fragments and particle systems, as they were the only two types of objects that would ever potentially be out of frame. The star fragments were culled with the radius obtained from their Collision component, and because of the uncertainty of the animated particle system's total radius at a given point in time, a value of 5 was used instead, as a wide estimate.

Entity-Component system

The entity-component system used a ComponentManager class, with a vector of GameObjects and a hashmap of vectors of Components, one vector for each first-level component. A GameObject is a name, and a hashmap of indices of any components that belong to it, and Components were inherited from a base Component class, with virtual Init and Update functions, as well as lifetime status boolean values. First-level derived components included Movement, Transform, Collision, Renderer, Collect, Particle, and DroneManager, which were used as interfaces to concrete classes with object-specific implementations. Initializing a game object involved creating derived components, and passing them to AddGameObject, which would place all of the components into the first empty location in the respective vector, which was implemented by maintaining an atomic priority queue for each vector to avoid vector resizing and race conditions, and creating and storing a GameObject that stored its indices. Destroying a GameObject would involve calling RemoveGameObject, which marked all of the components located at the vector and index referenced by the GameObject as killed, and pushed their index to the respective atomic priority queue. This would allow the vector index to be used again, eventually overwriting the location when a component to be added popped the index off the queue. Communication between components belonging to the same object took place through initializing shared pointers to components that needed to communicate with each other in the Init method, and the Update method was called every frame on active Components during the render loop. Because no components of the same type needed to communicate with each other, we could process their updates in parallel stages, using C++17's std::execution, or start an update or creation asynchronously and process it during unrelated updates using std::future. While setting up the project in this way took longer than expected, I think it paid off by the end of the quarter. We were largely able to write new functionality by inheriting from a number of the first-level components, add a resulting game object, and the rest would take care of itself.

Shading

Shading the star fragments and LUNA used a 3-component microfacet BRDF: a geometric shadowing function(GSF), a normal distribution function(NDF), and a fresnel function(F). The geometric shadowing function used was the Kelemen approximation of the Cook-Torrance GSF, the NDF used was the GGX microfacet NDF, and the Fresnel function was the Schlick's approximation function, with a few adjustments to add tunable values.

Character and Camera Animation

Each body part of the character is split up into individually animated objects. Each one has a desired orientation based on the input of the player and moves towards its desired orientation by slerping between its existing orientation and its goal quaternion. The player's head turns in the direction it wants to go and the player's body leans and rolls with the direction of motion it is accelerating in. Additionally, if you press ALT, you can see the eyes blinking with a scaled ellipse function in the fragment shader. The camera is positioned behind the player and changes its distance and FOV based on the speed of the player. It smooths to its desired position, and has several different positions based on the state of the character. It looks directly at the player and follows the character as it travels through the sand. The camera also collides with the terrain to prevent the terrain from blocking the view of the player.

Post Processing

Bloom is implemented using a modern technique used in engines like Unity. The pass takes the high valued colors above a threshhold, and then downsamples down to an extremely low resolution, and then recursively upsamples back to the base resolution while adding each pass together back into the final image. The game is rendered in high dynamic range and then tonemapped using ACES tonemapping (see references). Camera based motion blur was implemented, but the effect didn't give the feeling of speed we wanted, so we used a radial blur that started outside of the center of the screen that scales its strength with the player speed. This gives a similar effect as motion blur, but only on moving forwards and not the rotation of the camera. This effect gave our game the feeling of speed we were looking for.

Normal Mapping and Other Mappings

The main character is normal mapped and has several other maps used for lighting. Since we are using physically based rendering, we used a metallic map and a roughness map in calculating the shading, in addition to the normal and base texture map of the character.

Resources

ACES Tone Mapping Used course notes on BRDF shading math and physics Kelemen GSF GGX NDF Schlick's approximation of the Fresnel BRDF component Perlin Noise implementation Miniaudio website, with links to download and documentation NVIDIA Motion Blur Examples Reference Slides for Implementing Bloom tinyobjloader stb_image