Deviant 3D

By: Calvin Wong, Rafael Ostrea,
Sambodh Mitra, Chris Park

Summary

Did someone say waiting hours for a render? That's a fat NO for us, so we made Deviant3D, a realtime multiplayer shooter game (in 3D, obviously). Deviant3D incorporates CS 184 topics such as geometry processing, lighting, animation, and physical simulation to bring the world, characters, and varying interactable objects to life.

We used the open source Godot game engine to create this, and our problems were: Three of us were completely unfamiliar with Godot, and thus needed to learn the engine from scratch (sad face). Technical complications also arose, such as Godot's built in physics engine being non-deterministic (hard to sync the multiplayer, and also very funny glitches) and the inverse kinematics not quite being fully functional. To solve these two issues, we had to do a bit of reading (resources at bottom of website) and implemented our own physics and procedural animations.

Our final result was a nicely polished multiplayer game (we even threw in a sobel edge detection shader for the low price of $0.00 and our sanity). We developed varying enemy types, powerups to aid combat, procedurally generated terrain, and many stylistic improvements. From post-processing shaders to offering both third-person and first-person perspectives, our game shaped up to be a nice looking pile of fun!

Trailer

Music video (no technical details)





Original Video (Short demo, technicals, and why you should do this project)


Play it here!

Technical Details

So, you wanna know how this was made? What are you, a nerd? Ok ok, I'll tell you.

But first, before you go all eyes glazed from all the details, the most important take aways (I think) are that we actually got to apply a lot of the animation techniques that were glossed over, like heirarchical transforms (had to deal with a lot of local vs global transforms), inverse kinematics (or rather, the "discount" version), physics, and lastly making nice nonrealistic shaders.

We also learned a lot about keeping a larger codebase afloat (picking up a large solo project was, to say the least, tricky to teach everyone and modularize everything).

Alright, on with the technicals! Deviant3D was created using Godot, an open-source game engine, which made collision testing and basic rendering trivial. However, there were 3 main issues:

  1. Godot's inverse kinematics was not bug free.
  2. Godot's inbuilt physics was nondeterministic (so not multiplayer friendly).
  3. The default lighting was rather bland, and turning up settings caused performance issues, since the folks at Godot are still working on performance.

To solve 1, I used tweening, interpolation, and animation blend trees, and a custom follow the leader algorithm for the worm. For simpler things like changing the camera position when switching between third and first person, I used a similar approach to bezier curves, parameterizing as

\(\vec{P}(t) = ((1 - t) * \vec{S}) + (t * \vec{E})\)

where \(\vec{P}\) is our position, \(\vec{S}\) is our starting position, and \(\vec{E}\) is our ending position. From there, as our time step increased, I used Godot's tweening to handle choosing the correct value of \(t\), since \(t\) could follow a linear increase from 0 to 1, or quadratic, or any other function.

This was done for centering the gun (to get the point to aim at, I raycasted from the camera), aiming the grappling hooks, aiming down sights, smoothly increasing enemy speed, and so forth.

For first person animations, I used Godot's built in Animation Trees to blend between animation states, such as shooting transitioning to idle to walking and sprinting.

For the worm, it rotated towards the player except I tried using a weird hack to get interpolation to rotate at a more constant rate towards the player without having to learn about quaternions. Here we are trying to set \(\vec{T}_{t+1}\) to an interpolated transform that is looking towards the player:

\begin{array}{l} \vec{T}_{t} := \textrm{Transform of worm at time t} \\ \vec{P} := \textrm{Transform of worm looking at player} \\ \theta := \textrm{Angle between worm and player's forward basis vectors} \\ \vec{T}_{t+1} = lerp(\vec{T}_{t}, \vec{P}, dt * 4 / \theta) \\ \end{array}

I realized this didn't work because instead of dt, it should be the t parameter similar to bezier curves, but the effect actually provided some nice unpredictable worm paths, so I kept it as that. Then I simply recursively had the other worm segments rotate towards the next segment until we didn't have any segments left to rotate, and then kept the distance between segments at a constant value by directly setting the segment positions to move forward until they were at that distance.

To solve 2, nondeterministic physics, I used the built in collision detection and the Kinematic Body's move_and_slide function, which handles bouncing and sliding, given an input velocity (but it does not rotate the player, ie: apply torque) to create our own deterministic physics system. It went like this (I did not incorporate mass yet, so everything is treated as mass 1):

\begin{array}{l} \vec{F} = gravity + inputs + jump + hook + water \\ \vec{V} += \vec{F} * dt \\ \vec{P} += \vec{V} * dt \\ \end{array}

Of course, this is a massive oversimplification, I had lots of constants for air resistance and friction, which I used to dampen our current velocity so we didn't infinitely move off into space, had some for clamping our max velocity, yada yada yada. For water, I simply applied an upward force, and allow the player to move along its local forward z axis in any direction.

For the grappling hook, I decided that rather than using it like a stiff rope, I'd use it more like a bungee chord to make it more fun. So, I experimented with the stiffness and set the rest length to about a third of the player's height, and also made the hook force have a max velocity length so he didn't immediately fly off into oblivion.

To solve the problem of infinite circling when grappling (similar to when the tangential force equals the outwards force of a planet, which causes it to stay in orbit, I dampened the current velocity when I added our new velocity, like so:

\(\vec{V}_{t+1} = (\vec{V}_{t} * (1 - damp)) + (\vec{F}_{t+1} * dt)\)

After that our player would still circle, but slowly but surely reel in, which allowed for nice circular maneuvers while still having the nice feeling of a bungee chord.

The enemies also used a similar approach, using verlet integration with acceleration and velocity so they wouldn't infinitely circle but would have some momentum (having enemies that always move toward you aren't very satisfying).

For rocket jumping, I basically had when the bullet raycast detects something and detonates, I spawn a sphere, and anything inside the sphere takes damage and gets an immediate impulse from the center of the sphere to the object so that it's "pushed" away.


To actually create the world, it was almost entirely procedurally generated. For land, I created a 2D grid of squares of size \(s \times s\), then offset the vertices by a random amount \(\vec{R}\) such that \(\vec{R}\) was no more than \(\frac{s}{3}\) in distance away from its original position (and thus, the new quadrilaterals didn't intersect eachother).

Next was iterating over those and creating meshes for them. The vertices were the 2D grid positions except offset up and down by a random amount so each block was flat overall but the grid overall was not flat.

In terms of the very nitty gritty for mesh generation, I used the Godot ArrayMesh API to create 24 vertices (since each edge touched had 2 points, and there were 12 edges) for 12 triangular faces (a cuboid has 6 in real life, but non-triangle rendering is messy so they get split into 2 triangles) then calculated surface normals using the cross product between two edges on the face (getting the signs right were a pain).

I then used Open Simplex Noise using the x and z coordinates to generate a smooth height transition between neighboring blocks, which resulted in the rather nice transitions between dirt, grass, and sea.

Of course, this generation can be optimized, since we actually only need 8 vertices, and since the grid is connected, we can make it all a couple connected meshes to decrease the amount of draw calls.

For asteroids and trees, they both use similar approaches, where I go through a grid and then using open simplex noise and x and z coordinates, I choose whether there should be an asteroid there or not, and then give them random scaling values. We used Godot's multimeshes to have all the asteroids and trees sent as a single draw call each. However, this meant we needed to set the transforms for each instance (which was a bit more involved than simple Euler angles). This involved setting basis vectors, rotating along the three basis vectors, then finally figuring out how to do a translate (since the built in Transform.translate translated along the basis vectors, rather than adding to the origin). Luckily we figured out we could just offset the origin AFTER we had manipulated all the basis vectors.

In fact, I used Simplex Noise for the camera shake (if you can't tell by now, I quite enjoyed the smoothness) by setting its rotation to random values based on time. Normally, this would look very random and jittery, but with simplex noise it became a little smoother. Stylistically, I don't think it looks nicer (that's subjective of course) but it certainly should reduce motion sickness.

For 3, graphical problems, we used GPU accelerated shaders and customizable graphics settings to solve this. If you have an absolute potato pc you can turn everything off, so only basic lighting (no shadows) will happen. However, the Sobel Edge Detection shader (reference) is low cost (since it's a post processing effect that only processes the 2D screen image) and makes the game look very nice, even at low settings.

The Sobel filter was included as a part of the HUD and covers the entire screen. The sobel edge detection algorithm that we learned in class was implemented by sampling the screen texture, extracting the horizontal and vertical gradients for each pixel using the \(G_x\) and \(G_y\) matrices defined in lecture, and then computing the final square rooted result (as an rgb value). We then colored the edges by using black with its alpha value being the average of the rgb values of the final result.

For other shaders, as a last touch on Open Simplex noise, I used that to generate the sea foam in the water shader (reference). The water itself was a subdivided plane. For the foam around half submerged objects effect, I used the depth of the camera rays (if it hits the water then something else very quickly, we should create the foam, otherwise only generate the noisy foam), and used sine and cosine as functions of time to generate some nice waves using vertex displacement. Lastly we kind of used the hack found online where you take the screen texture, offset and distort it to get the reflection, although this backfired (if you fly really high, you'll see why) so maybe we'll fix it at some point.

To get the toon/flatter shading effect of the water, I clamped values between certain ranges to be one value for that range, ie:


						if (noise_sample > 0.0 && noise_sample < 0.4) albedo = 0.1;
						if (noise_sample > 0.4 && noise_sample < 0.8) albedo = 0.0;
						if (noise_sample > 0.8) albedo = 1f;

						Which could be further optimized into no if elses:

						float albedo = 0.1 * float(noise_sample > 0.0 && noise_sample <= 0.4) + 1.0 * float(noise_sample > 0.8);
				

In terms of differences between our implementations and the references, we optimized the if elses so it'd run faster, and made small tweaks to the parameters (such as using black edges instead of the color in our reference, or using different noise parameters).

The menu blur shader used mipmaps and bilinear interpolation between the mipmaps (extremely fast on the GPU).

For snapping to surfaces, I used raycasts to detect the surfaces that players were standing or moving towards, then reoriented the player's basis vectors like so:

\begin{array}{l} \vec{Z} := \textrm{Player's forward vector (normalized)}\\ \vec{N} := \textrm{Surface normal, normalized} \\ \textrm{Player's up vector = }\vec{N}\\ \textrm{Player's side vector = }\vec{N} \times \vec{Z}\\ \end{array}

with the forward vector staying the same. This made the player face the same direction but with their up oriented towards the surface normal. I then interpolated between the two states to get a smooth rotation.

There was also the problem of gimbal lock, which I fixed using clever parenting, where I had a top parent P that controlled the player's up vector, a child X that controlled the side to side rotation, and a child Y that controlled the up or down movement. I then clamped the up and down angle between -pi/2 and pi/2 so that it didn't bend over backwards and cause our mouse moving left to rotate right. Using this method allowed side to side mouse movement only affecting child X's local rotation, so the child Y's rotation didn't get locked (since child Y's rotation only moved up or down). Having it all under P allowed us to snap to surfaces (ie: reorient our up) while preserving child X and Y's rotations, so we didn't have to do any complex math to recalculate angles.

Two powerups implemented were the Gravity powerups, each of which either increase or lower the character's gravity by a factor of two. This was implemented by taking the gravity attribute of the player when it interacts with the powerup, and then choosing to either double or halve it. In addition, the powerup will be rendered uninteractable for a set period of time. After a timer of 15 seconds expires, the gravity is then restored to what it originally was.

Alongside the Gravity powerups was an Attack Buff, which increases the players shots and explosion damage by a factor of 2. This was done in a similar method to the Gravity powerups, where once the player interacts with the object, the player's global variables responsible to damage are doubled for a set period of time. In addition, the powerup is once again rendered uninteractable to prevent multiple usages.

Results

Contributions

First off, want to thank Rafael, Sambodh, and Chris for dealing with Calvin's extensive codebase, and for picking up Godot from scratch :)

Calvin implemented:
  1. Starter code/ideas
  2. Debugging everyone's code, incorporating into main game
  3. Physics (Verlet Integration Style)
    1. Player (gravity, walk/sprinting, friction)
    2. Grappling
    3. Rocket Jumping/Explosions
    4. Enemies (flying, land, worm)
    5. Water
  4. Snapping to surfaces feature (flipping)
  5. Shaders:
    1. Menu blur
    2. Water
    3. Sketch
    4. Explosion
  6. Procedural animations (done through code)
    1. Worm
    2. Guns
    3. Grappling Hook Gear
    4. Player
    5. Switching camera positions
    6. Menu camera parallax effect
  7. Camera effects:
    1. Toggling third/first person
    2. Camera Shake
    3. Aim Down Sights
  8. Random terrain generation
    1. Land (includes mesh manipulation, eg: vertex, normal calculations)
    2. Trees
    3. Asteroids
  9. Customizable graphics ability
  10. Material configurations, particles
  11. Writeup website formatting, presentation revisions
  12. Some 3D modeling (asteroids), 2D art (reticle, hitmark)
For not-as-related-to-CS184 things, Calvin did:
  1. Networking/Multiplayer
  2. GUI (pause menu, etc.), responsiveness to resizing app
  3. Customizable controls
  4. Sound design
  5. Exporting to the web
  6. Some hidden easter eggs if you hover over GUI elements ;)

"Not going to lie, this was like my baby, which is why the website and game look and feel like they've been treated so well" - Calvin


Sambodh worked on:
  1. Created concept of powerups
  2. Powerups developed:
    1. Gravity Increase
    2. Gravity Decrease
    3. Attack Buff (Extra damage per shot and explosion)
  3. Handled all Administrivia:
    1. Completed and submitted milestone form
    2. Completed and submitted final form
    3. Kept group updated on deadlines/filled out Presentation signup times, etc
    4. Helped with Website outline
    5. Created Final Presentation Slides
    6. Helped with Video Demo for Website
    7. Wrote Project Abstract for Website

"Not going to lie, learning Godot proved that it's an impressive Game Engine, but Unity is still much better" — Sambodh

Rafael dealt with:
  1. Integrated powerups with the procedural terrain generation
  2. Powerups developed:
    1. Friction Increase
    2. Friction Decrease
    3. Implemented powerup availability timer
  3. Implemented physics when shooting objects
    1. Rotating object with respect to surface normal depending on point of impact
    2. Handling effect of gravity on object after aforementioned rotation
    3. Moving object in accordance with Law of Conservation of Momentum
    4. Animated above object movements
  4. Added objective object that generates new game upon player contact
  5. Developed new enemy types and added their corresponding spawning methods
    1. Wolf Enemy: Grounded, but faster than typical enemies
    2. Arrowhead Enemy: Flying, has alternating periods of high speed and low speed

"Not going to lie, editing this website gave me more headaches than the entire rest of the project." - Rafael

Chris did:
  1. Implemented the toon shader
    1. Post-processed the 3D graphics with a 2D shader layer using OpenGL
    2. Implemented using sobel edge detection algorithm to render edges around each 3D mesh, making the game look cartoon-like
  2. Worked on demo
    1. Filmed scenes for the demo that shows off many of its features, including multiplayer features, fighting the worm, snapping to surfaces, etc.
    2. Organized and helped edit the final demo

"Not going to lie, I had a fun time with this as my final project before I graduate but thank god its over." — Chris