Skip to content

LinyanZ/thirdPersonShooter

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

Third Person Shooter

This is part of my report for the university subject COMP30019 - Graphics and Interaction, uploaded for my portfolio. The actual code is hidden on purpose as this is one of my university assignments.

Table of contents

UI

Main Menu:

Main Menu

Open up the game, and the main menu would appear.

Press START to start a new game.

Press OPTIONS to change the resolutions, graphics quality, game difficulty, etc.

Press CONTROLLER to view the key mapping.

Press CREDIT to see the game producers.

Press QUIT to quit the game.

In Game:

Our game uses traditional gaming key mappings.

Explanation Key
Move Around W / A / S / D
Jump Space Bar
Sprint Left Shift
Look Around Mouse
Aim Right Mouse Button
Fire Left Mouse Button
Pause / Unpause Escape

There’s also a tutorial at the start of the game to help the player get familiar with the controls.

HUD:

HUD

There is a white crosshair at the center to help the player aim at the enemy. The orange crosshair would appear when the player hits the weak point of an enemy.

The mission panel is at the top-right corner. The content would be updated as the player progresses forward. There is also a target indicator on the screen with the distance attached to provide some guidance.

The reddish color around the screen is used to indicate the player’s health.

Pause Menu

HUD

Press RESUME to unpause the game.

Press MAIN MENU to return to the main menu.

Press OPTIONS to change the resolutions, graphics quality, game difficulty, etc.

Press CONTROLLER to view the key mapping.

Press QUIT to quit the game.

Graphics Pipeline

We’ve created several custom shaders to achieve the final look of our game. This includes a vertex shader, dozens of fragment shaders, and a few image effect shaders.

Most of the game objects are using a toon shader material. We come to this decision because this is a low-poly game, and the cartoon effect combines nicely with the low-poly geometry. This shader modifies the fragment shader stage of the graphics pipeline, and all the details are fully explained under the Shaders section.

We also wrote a vertex shader to create a shield effect with floating tiles. In the vertex shader stage, different parts of the mesh are offset by different amounts, and the amount is changing as time goes by. Full details of this shader are under the Shaders section as well.

The laser material under the Particles section also utilizes a custom fragment shader. The UV is moving throughout the time, combined with some other noise textures, to create a dissolving, glowing and energetic effect.

At the output-merger stage, there are two more image effect shaders implemented.

One is a fog shader. This one utilizes the depth texture generated by the camera and blends the fog color to the original color based on the depth.

Fog - Before Fog - After
Fog - Before Fog - After

Another one is a grayscale shader. This is used when the player’s health is critical, to inform the player that he needs to be very careful now.

Low Health Shader

Last but not the least, the post-processing. We added Unity’s post-processing to improve our game’s visuals. It includes all the common post-processing, such as Anti-Aliasing, Bloom, Color Grading, Motion Blur, etc.

Post Processing - Before Post Processing - After
Low Health Shader Low Health Shader

Post-processing is also used to give some visual feedback when the player kills an enemy.

Low Health Shader

Camera Motion

Like all other third person shooter games, we need our camera to orbit around the player at a distance. When the player is aiming, the camera also needs to move forward to create a zoom-in effect.

Instead of writing complex codes to calculate the camera’s position, my approach is to set up a “camera spring” system.

Camera Spring

The CameraSpring would follow the player’s position and act as a pivot. The actual camera is attached to this pivot and offsets backward. Upon mouse inputs, I just rotate the pivot, then the camera would nicely orbit the player, without any complex math involved.

When the player is aiming, the camera needs to move closer to the player. Also, I want the camera to offset a bit to the right so that the center of the screen is not blocked by the character’s body.

Aiming

This is much harder than I thought. I tried to do some complex math but that didn’t work out great.

In the end, my hacky solution is to have another empty game object RawPosition to keep track of the original position of the camera. The actual camera is attached to this game object. When the player is aiming, a local position offset is added to the camera.

The camera system also needs to detect collision or occlusion. Once there’s something in between the character and the camera, the camera needs to move closer towards the character so that our player could see what’s happening.

To do this, my solution is to perform a ray cast from the pivot position towards the camera’s position. If that hits something, I then lerp the camera to the collision point.

Camera Collision

Shaders

Toon Shader

Toon Shader

File Location: Assets/Shaders/ToonShader

Since we are building a low poly game, a toon shader should fit our style pretty well.

As demonstrated in the gif above, there are plenty of options available for you to customize. You can adjust the number of shadow bands, several colors, glossiness, and the rim.

Next, I will briefly talk about how I created this shader.

First, create a fresh unlit shader, add a main texture property and a tint color property.

Sample the main texture and multiply it by the tint color.

_MainTex ("Texture", 2D) = "white" {}
_Tint ("Tint Color", Color) = (1,1,1,1)

...

fixed4 tex = tex2D(_MainTex, i.uv);
return tex * _Tint;

This is how it’s look like. Pretty simple.

Main Texture + Tint Color

Now we can use the dot product between the face normal and the light direction to simulate diffusion. _WorldSpaceLightPos0 is the position of the “sun” (directional light).

The dot product would give us a value between -1 and 1. When the angle between two vectors is smaller than 90 degrees, the dot product would be positive. The smaller the angle, the greater the value.

We can multiply that to our base color like this:

float3 normal = normalize(i.worldNormal);
float NdotL = dot(_WorldSpaceLightPos0, normal);

return tex * _Tint * NdotL;

Diffusion

Next, to make it cartoonish, we need to divide the lighting into several bands.

A bit of math can achieve that.

_ShadowBands("Shadow bands", Range(1,10)) = 1

...

float lightIntensity = round(NdotL * _ShadowBands) / _ShadowBands;
return tex * _Tint * lightIntensity;

Shadow Bands

It starts to become interesting now. But the dark side is way too dark. We can simply add some fake ambient lighting to make it brighter.

[HDR]
_AmbientColor ("Ambient Color", Color) = (0.4,0.4,0.4,1)

...

float4 ambient = (1.0 - lightIntensity) * _AmbientColor;
return tex * _Tint * lightIntensity + ambient;

Ambient Color

The next step is to add some specular. I’m using the same Blinn-Phong model as described in the workshop, so I won’t explain it here.

However, I added a few changes such as multiplying the specular intensity by the variable Glossiness and clamping the fadeout at the edge to make it toon-like.

[HDR]
_SpecularColor("Specular Color", Color) = (0.9,0.9,0.9,1)
_Glossiness("Glossiness", Float) = 32

...

/* 
    Specular Blinn-Phong 
*/
float3 viewDir = normalize(i.viewDir);

// H = (L + V) / (|| L + V ||)
float3 halfVector = normalize(_WorldSpaceLightPos0 + viewDir);
float NdotH = dot(normal, halfVector);

// _Glossiness ^ 2 makes it easier to control the size of the specular
float specularIntensity = pow(NdotH * lightIntensityRaw, _Glossiness * _Glossiness);

// clamping the fadeout at the edge
float specularIntensitySmooth = smoothstep(0.005, 0.01, specularIntensity);
float4 specular = specularIntensitySmooth * _SpecularColor;

return tex * _Tint * (lightIntensity + ambient + specular);

Specular

One more thing: the rim at the edge.

To highlight the edge, we can simply calculate the dot product between the face normal and the view direction, and then subtract it from 1. Then multiply this value by some color and add it to our output.

[HDR]
_RimColor("Rim Color", Color) = (1,1,1,1)

...

float4 rimDot = 1 - dot(viewDir, normal);
float4 rim = rimIntensity * _RimColor;

return tex * _Tint * (lightIntensity + ambient + specular + rim);

Rim1

We only want the rim to appear on the illuminated side. To achieve this, we can multiply the NdotL from the previous step to the rimDot. Also, clamp the fadeout to be toon-like and add some helper variables to control the amount and the shape of the rim.

_RimAmount("Rim Amount", Range(0, 1)) = 0.716
_RimThreshold("Rim Threshold", Range(0, 1)) = 0.1

...

float rimIntensity = rimDot * pow(NdotL, _RimThreshold);
rimIntensity = smoothstep(_RimAmount - 0.01, _RimAmount + 0.01, rimIntensity);
float4 rim = rimIntensity * _RimColor;

Rim2

Rim3

Now it looks great! But if we change the main light in the scene, our shader would still look the same. So now we need to add the main light color _LightColor0 to the shader.

float4 light = lightIntensity * _LightColor0 + ambient;
...
float4 specular = specularIntensitySmooth * _SpecularColor * _LightColor0;
...
float4 rim = rimIntensity * _RimColor * _LightColor0;

return tex * _Tint * (light + specular + rim);

Main Light

However, this only takes account of the main light (directional light) in the scene. The shader won’t be affected if we add some other point lights/spotlights.

After tons of googling, I learned that I have to add another pass to blend the other lights on top of the original color. This is done by setting the first (original) pass to ForwardBase and adding a second pass with the tag ForwardAdd.

The content of the second pass is pretty similar to the first pass, so I won’t expand it again.

SubShader
{
    Tags {"Queue" = "Geometry" "RenderType" = "Opaque"}
    
    // pass for the directional light (main light)
    Pass
    {
    	Tags 
        { 
        	"LightMode" = "ForwardBase"
        }
        
        ...
    }
    
    // pass for each individual light (additive)
    Pass
    {
    	Tags 
        { 
        	"LightMode" = "ForwardAdd"
        }
        Blend One One // Additively blend this pass with the previous one(s). This pass gets run once per pixel light.
        
        ...
    }

We can now get some good-looking lights on our shader thanks to the second pass!

Other Lights

Finally, we need our shader to cast and receive shadows, like everybody else. This is done by adding FallBack “VertexLit” at the bottom of my shader. In my understanding, Unity would add all of the SubShaders inside this VertexLit file to my shader and use them.

P.S. I forgot to comment out this line when taking the screenshot, so all the spheres above are casting shadows on the floor.

This is how it looks like at the end:

Showcase

Shield Effect Shader

In the final stage of our boss fight, the boss would spawn a shield to prevent damage from the player. The shield looks exactly like this, but muuuuuuch bigger.

This shader is inspired by this YouTube tutorial Unity VFX Graph - Shield Effect Tutorial - YouTube.

However, in that tutorial, the effect is created using shader graph, which is not supported by the Unity built-in render pipeline.

So I followed the logic and tried to create a similar shader by hand.

Mesh

File Location: Assets/Shaders/ShieldShader

To create this shield effect, first, we need a shield mesh.

Mesh

Next, create a fresh unlit shader. This time we need it to be transparent.

Tags 
{ 
	"RenderType"="Transparent" 
	"IgnoreProjector"="True" 
	"Queue"="Transparent" 
}

We want to have different colors on the front and the back of the faces, so we need 2 passes. One is to draw the front face, and the other one is to draw the back faces. In the end, we need to blend these two passes together. The setup looks something like this:

SubShader
{
	Tags { ... }
	LOD 100
	Blend SrcAlpha OneMinusSrcAlpha
	ZWrite Off
	
	// only render the front faces
	Pass
	{
		Cull Back
		...
	}
	
	// only render the back faces
	Pass
	{
		Cull Front
		...
	}

The second pass is very similar to the first pass, so I will just briefly talk about the first pass.

First, throw the texture onto the mesh and multiply it with the desired color.

_MainTex ("Texture", 2D) = "white" {}
[HDR]
_FrontColor ("Front Color", Color) = (1,1,1,1)
[HDR]
_BackColor ("Back Color", Color) = (1,1,1,1)

...

fixed4 col = tex2D(_MainTex, i.uv);
col *= _FrontColor; // col *= _BackColor;

return col;

Two Passes

This is too bright. Also, I don’t want the back faces to be very noticeable. So the dot product, once again, comes in handy.

I only want the back faces to be visible at the edges, so I add 1 to the dot product between the face normal and the view direction. I then divide the value by 2 so the end result lies between 0 ~ 1.

float3 normal = normalize(i.worldNormal);
float3 viewDir = normalize(i.viewDir);
float NdotV = dot(normal, viewDir);
float intensity = saturate((NdotV + 1) / 2);

return col * intensity;

Blend Properly

To highlight the edges of each tile, it’s actually pretty simple with the texture we prepared earlier. We first check if the alpha channel of the texture is greater than some threshold. If it is greater, we multiply it by some edge color and then add it to the output.

[HDR]
_EdgeColor ("Edge Color", Color) = (1,1,1,1)
_EdgeHighlight("Edge Highlight", Range(0, 10)) = 1
_EdgeThreshold("Edge Threshold", Range(0, 1)) = 1

...

float edge = col.a > _EdgeThreshold ? 1 : 0;
return col * intensity + edge * _EdgeHighlight * _EdgeColor;

Edge Highlight

Next is to create some fresnel effect around the edge of the sphere.

This is very similar to the rim effect in the toon shader. We first calculate the dot product between the face normal and the view direction, and then subtract it from 1.

However, after adding the fresnel effect, our shader becomes too bright, so I also added a float variable _ReduceMainCol to reduce the original color intensity.

_ReduceMainCol("Reduce Main Color", Range(0, 10)) = 2
[HDR]
_FresnelColor ("Fresnel Color", Color) = (1,1,1,1)
_FresnelPower("Fresnel Power", Range(0, 10)) = 2

...

float4 fresnelDot = 1 - NdotV;
float4 fresnel = _FresnelColor * fresnelDot * _FresnelPower;

return col * intensity * pow(NdotV, _ReduceMainCol) + (edge * _EdgeHighlight * _EdgeColor) + fresnel

Fresnel Effect

Now it looks great!

Here comes the fun part, the floating tiles.

We need to offset the tiles along its normal. To achieve this, we need to do some fancy math inside the vertex shader. (BTW everything above is in the fragment shader.)

First, we multiply the face normal by some _VertexAmount. Second, to animate the up-and-down, we can use sin() along with the time passed.

_VertexAmount ("Vertex Amount", Range(0, 1)) = .1
_VertexFrequency("Vertex Frequency", Float) = 1

...

v2f vert (appdata v)
{
	float4 vertex = v.vertex;
	float4 amount = sin(_Time.x * _VertexFrequency) + 1;
	vertex += float4(v.normal, 1) * _VertexAmount * amount;
	o.vertex = UnityObjectToClipPos(vertex);
	...
}

Moving Vertex

Nice.

But we want individual tiles to float separately.

My solution is to multiply the frequency variable by a random float so that each vertex would oscillate at a different rate. But we also need to make sure that every vertex on the same tile oscillates with the same frequency.

Luckily, I found this black magic on some forum, to generate a “random” float based on a given input. Now I can simply pass in the face normal to get a “random” frequency for each tile.

// black magic
float random (float2 uv)
{
	return frac(sin(dot(uv,float2(12.9898,78.233)))*43758.5453123);
}

...

float4 amount = sin(_Time.x * _VertexFrequency * random(v.normal)) + 1;

This is the end result:

Floating Tiles

Particles

In the second stage of our boss fight, the boss would shoot out a laser towards the player.

This is the laser effect:

Laser Effect

File Location: Assets/Particles/vfx_Charge

File Location: Assets/Particles/vfx_Impact

This is a composite effect containing multiple particles and also some shaders. I will only discuss the particles here since this report is getting very long...

vfx_Charge is the energy sphere at the start of the laser. vfx_Impact is the particles and decals at the end of the laser.

Since the particle section is only worth 2 marks, I won’t be explaining all the details as I did in the shader section. I will only show you how I put everything together to create this effect.

vfx_Charge

This effect contains 5 sub-particles.

The background: glowing spheres.

Laser Effect

Two sparks, one narrower but longer, one fatter but shorter.

Sparks

Two shockwaves, one red, one blue.

The frequencies and sizes of these two are different, to give randomness and an energetic feeling.

Shockwaves

vfx_Impact

This effect is simpler than the previous one. It only contains 3 different effects.

The background glowing hemisphere.

Glowing Hemisphere

The sparks.

Sparks

The decals on the floor.

Decal

References

Shaders and Particles

AI

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published