Portal: Overlord Edition

Overview of Page

Description

This project was an end assignment for a school course called Graphics Programming 2, which consisted of implementing graphics features in a given engine and developing a game using your engine at the end of the course. Ever since playing Valve’s famous Portal, I’m an absolute fan, and though being warned about the difficulties in recreating the game, I never gave up on the idea and continued my research. As time passed I can say with confidence this is definitely one of my favorite solo projects. I have learned so much through the development and I’m more eager than ever to keep exploring game features and tackling the challenges that come with them. In my free time, I have revisited the project to work on fixing the remaining bugs concerning collisions and teleportation, and can proudly present a working demo today.

The "Overlord" Graphics Engine

The Overlord Engine is a given base engine that allows for 3D rendering through the DirectX11 API and physics through the PhysX API, made possible by a few lecturers (see contributors at bottom of the page). During the semester I added/implemented features explained to us by our lecturers, to make the engine more fit to make an actual game in. Though having its restrictions and being solely responsible for the development of the engine, it was a genuinely interesting learning process that sparked more curiosity in the field of graphics programming.

Implemented Features:

The Portal Game

Overview of Game Features:

Prototyping in Unity

My initial prototypes were done in the Unity engine for a couple of reasons. One reason is that my main reference video for how Portals are made (see credits) was made in Unity, and also because the engine offers a visual interface and much more functionality to probe and figure out the scope of the game. The video alongside the given resources and papers helped me understand the concept of what’s going behind the scenes and made it possible to get a pretty neat prototype running. 

Portal Implementation from Scratch

Seamless Portals

Creating seamless portals was probably the easiest step along the way. When shooting your portal onto a wall, a raycast is done to determine what object is hit and where it’s hit. We can check for collision with objects that allow portals to be placed upon, which then gives us further information about the size of the object, which we need to determine our final portal position along with the portal size information. After this, you know everything to determine how to transform the portal within the boundaries of the object, in my case simple flat walls. 

Virtual Camera's & Render Targets

Getting to make Portal from scratch became a different story now. I had to implement a part of the engine that would render virtual cameras to new render targets and store them so they can be used as input for the portal cutout shader (what is displayed on a portal). There were issues in the base implementation of the math files, which gave me a hard time figuring out the camera transformations. It’s important that each camera’s transformation needs to stay relative to its portal the same way the player’s transformation is relative to the portal it’s looking through. Due to the unreliable math issue (yet to be solved), I ended up using an enum class that can tell me the portal’s direction and is determined when shooting your portal on a wall based on the hit-normal. Depending on the enum’s value, different transformation rules would apply to the camera.

The distortion on the portals gets fixed with the next feature mentioned below

Cutout Shader & Oblique Projections

With the base functionality working, it was time to fix the distorted image displayed on the portals. The reason for this distortion is because regular shaders use the UV coordinates of the mesh to determine how the texture is pasted on it. In our case, it is basically squashing the rendered scene image to fit the portal’s surface. We need to “cut out” the part of the game scene we want to see, which can be determined by the screen-space positions of the portal mesh (click here to see shader code). Swapping out our UV’s to screen-space positions to sample from will fix this distortion, but we’re not done. Our last step here would be to add Oblique Projections. To simplify the concept: by changing our projection matrix to an oblique matrix we can transform the virtual camera’s near clipping plane to clip everything in front of its portal away that would normally block the view. Thus giving us that illusion of looking through a real portal window (click here to see code snippet for oblique projections). 

Teleporting

The concept here is simple but came with its difficulties as well. Each portal has a trigger volume in front of it that will disable the wall’s collision on which the portal is situated. This allows us to walk through the wall as long as we’re in this portal’s trigger volume. With some linear algebra, we can determine when exactly we have crossed the center of the portal, on which we teleport to the other portal’s position. Here I encountered issues with collision meshes that would snap the player in the wall or in the ground but was later fixed by calculating the desired offset when teleporting (click here to see code snippet for teleporting travelers).

Most Interesting Code Snippets

Portal Traveler Update (pre- and post-teleport)
for (PortalTraveller* pTraveller : m_pTravellers)
{
	//Calculate traveller offset torwards this portal 
	const DirectX::XMFLOAT3& travellerPos = pTraveller->GetTransform()->GetWorldPosition();
	DirectX::XMVECTOR xmTravellerPos = DirectX::XMLoadFloat3(&travellerPos);

	const DirectX::XMFLOAT3& thisPortalPos = GetTransform()->GetPosition();
	DirectX::XMVECTOR xmThisPortalPos = DirectX::XMLoadFloat3(&thisPortalPos);

	//Current offset from portal
	DirectX::XMVECTOR xmTravellerOffsetFromPortal = DirectX::XMVectorSubtract(xmTravellerPos, xmThisPortalPos);
	
	//Ignore on first entry, if not ignored this can cause problems with the checking of previous offset
	if (pTraveller->IsFirstyEntry())
	{
		//Set up previous distance for next frame
		DirectX::XMFLOAT3 prevDist{};
		DirectX::XMStoreFloat3(&prevDist, xmTravellerOffsetFromPortal);
		pTraveller->SetPreviousDistanceToPortal(prevDist);
		pTraveller->SetFirstEntry(false);
	}

	//Previous offset from portal
	const DirectX::XMFLOAT3& prevTravellerOffsetFromPortal = pTraveller->GetPreviousDistanceToPortal();
	DirectX::XMVECTOR xmPrevTravellerOffsetFromPortal = DirectX::XMLoadFloat3(&prevTravellerOffsetFromPortal);

	//Current portal side
	DirectX::XMVECTOR xmPortalFwd = DirectX::XMLoadFloat3(&m_Direction);

	DirectX::XMVECTOR xmDot = DirectX::XMVector3Dot(xmTravellerOffsetFromPortal, xmPortalFwd);
	float dot{ 0.f };
	DirectX::XMStoreFloat(&dot, xmDot);
	int portalSide = (dot < 0.f) ? -1 : 1;

	//Old portal side
	DirectX::XMVECTOR xmOldDot = DirectX::XMVector3Dot(xmPrevTravellerOffsetFromPortal, xmPortalFwd);
	float oldDot{ 0.f };
	DirectX::XMStoreFloat(&oldDot, xmOldDot);
	int portalSideOld = (oldDot < 0.f) ? -1 : 1;

	//Teleport the traveller if it has crossed from one side of the portal to the other
	if (portalSide != portalSideOld)
	{
		//Get portal position, offset the Y with quarter of the height (to get position slightly abvoe the bottom y-value)
		float quarterHeight = m_pOtherPortal->GetHeight() / 4.f;
		DirectX::XMFLOAT3 otherPortalPos = m_pOtherPortal->GetTransform()->GetWorldPosition();
		otherPortalPos.y -= quarterHeight;
		DirectX::XMVECTOR xmOtherPortalPos = DirectX::XMLoadFloat3(&otherPortalPos);

		//Get portal's direction to offset the traveller in front of portal
		const DirectX::XMFLOAT3& otherPortalDir = m_pOtherPortal->GetDirection();
		DirectX::XMVECTOR xmOtherPortalDir = DirectX::XMLoadFloat3(&otherPortalDir);
		xmOtherPortalDir = DirectX::XMVector3Normalize(xmOtherPortalDir);
		xmOtherPortalDir = DirectX::XMVectorScale(xmOtherPortalDir, 0.1f);

		//Teleport traveller with offsetted position
		DirectX::XMVECTOR xmTeleportPos = DirectX::XMVectorAdd(xmOtherPortalPos, xmOtherPortalDir);
		pTraveller->GetTransform()->Translate(xmTeleportPos);
		
		//Calculate the needed offset to make the character face the right way when going out the portal
		DirectX::XMFLOAT3 travellerFwd = pTraveller->GetTransform()->GetForward();
		travellerFwd.y = 0.f;
		DirectX::XMVECTOR xmTravellerFwd = DirectX::XMLoadFloat3(&travellerFwd);
		xmTravellerFwd = DirectX::XMVector3Normalize(xmTravellerFwd);

		DirectX::XMFLOAT3 camFwd = m_pOtherPortal->GetPortalCamObj()->GetTransform()->GetForward();
		camFwd.y = 0.f;
		DirectX::XMVECTOR xmCamFwd = DirectX::XMLoadFloat3(&camFwd);
		xmCamFwd = DirectX::XMVector3Normalize(xmCamFwd);

		DirectX::XMVECTOR xmAngle = DirectX::XMVector3AngleBetweenVectors(xmCamFwd, xmTravellerFwd);
		float angle{};
		DirectX::XMStoreFloat(&angle, xmAngle);
		angle = DirectX::XMConvertToDegrees(angle);

		//Determine sign of angle
		DirectX::XMVECTOR xmCross = DirectX::XMVector3Cross(xmCamFwd, xmTravellerFwd);
		DirectX::XMVECTOR xmUp{ 0.f, 1.f, 0.f };
		DirectX::XMVECTOR xmSign = DirectX::XMVector3Dot(xmUp, xmCross);
		
		float product = 0.f;
		DirectX::XMStoreFloat(&product, xmSign);
		if (product < 0.f)
			angle = -angle;

		//Offset the new given offset based on the current angle
		if (AreEqual(angle, -90.f, 1.f) || AreEqual(angle, 90.f, 1.f))
		{
			angle -= 180.f;
		}
	
		//Pass rotation offset needed to turn in the right direction
		pTraveller->SetRotationOffsetY(angle);
		pTraveller->SetFirstEntry(true);

		//Play travel sound
		SoundManager::GetInstance()->GetSystem()->playSound(m_pTravelSound, 0, false, &m_pChannel);
		m_pChannel->setVolume(m_Volume);
	}
	else
	{
		//Set up previous distance for next frame
		DirectX::XMFLOAT3 prevDist{};
		DirectX::XMStoreFloat3(&prevDist, xmTravellerOffsetFromPortal);
		pTraveller->SetPreviousDistanceToPortal(prevDist);
	}
}
Creating Near-Clipping Plane
//Create clipping plane
const DirectX::XMFLOAT3& portalPos = GetTransform()->GetWorldPosition();
DirectX::XMVECTOR xmPortalPos = DirectX::XMLoadFloat3(&portalPos);

const DirectX::XMFLOAT3& portalNormal = GetDirection();
DirectX::XMVECTOR xmPortalNormal = DirectX::XMLoadFloat3(&portalNormal);
DirectX::XMVECTOR xmInvPortalNormal = DirectX::XMVectorScale(xmPortalNormal, -1); //rotate 180 degrees

//Camera distance
auto xmDot = DirectX::XMVector3Dot(xmPortalPos, xmInvPortalNormal);
float camDist{};
DirectX::XMStoreFloat(&camDist, xmDot);

//Don't use oblique clip plane if very close to portal as it seems this can cause some visual artifacts
float nearClipLimit = 0.3f;
if (abs(camDist) > nearClipLimit)
{
	//Create and set clipping plane vector
	DirectX::XMFLOAT4 clipPlane{ portalNormal.x, portalNormal.y, portalNormal.z, camDist };
	m_pPortalCam->UseObliqueProjection(true);
	m_pPortalCam->SetClippingPlane(clipPlane);
}
else
{
	m_pPortalCam->UseObliqueProjection(false);
}
Oblique Projections
//Apply oblique projections if set
if (m_UseObliqueProj)
{
	//Get clip plane
	auto clipPlane = m_ClippingPlane;
	auto xmClipPlane = DirectX::XMLoadFloat4(&clipPlane);

	//Transform our clip plane with the inverse transpose of the view 
	auto xmViewInvTransposed = DirectX::XMMatrixTranspose(viewInv);
	xmClipPlane = DirectX::XMPlaneTransform(xmClipPlane, xmViewInvTransposed);
	DirectX::XMStoreFloat4(&clipPlane, xmClipPlane);

	//Offset for near plane floating point errors
	clipPlane.w += (0.15f * clipPlane.w);
	xmClipPlane = DirectX::XMLoadFloat4(&clipPlane);

	//Now we calculate the clip-space corner point opposite of the clipping plane
	DirectX::XMFLOAT4 q{};
	q.x = Sign(clipPlane.x);
	q.y = Sign(clipPlane.y);
	q.z = 1.f;
	q.w = 1.f;

	//Load in projection matrix
	DirectX::XMFLOAT4X4 matrix{};
	DirectX::XMStoreFloat4x4(&matrix, projection);

	//Transform Q into camera space (can be achieved through projection matrix)
	q.x = q.x / matrix._11;
	q.y = q.y / matrix._22;
	q.z = 1.0f;
	q.w = (1.0f - matrix._33) / matrix._43;

	auto xmQ = DirectX::XMLoadFloat4(&q);
	auto xmDot = DirectX::XMVector4Dot(xmClipPlane, xmQ);
	float dot{};
	DirectX::XMStoreFloat(&dot, xmDot);
	float a = 1.f / dot;

	auto xmM3 = DirectX::XMVectorScale(xmClipPlane, a);
	DirectX::XMFLOAT4 m3{};
	DirectX::XMStoreFloat4(&m3, xmM3);

	//Final matrix composition, replace the third column
	matrix._13 = m3.x;
	matrix._23 = m3.y;
	matrix._33 = m3.z;
	matrix._43 = m3.w;

	//Projection matrix done, load back into projection
	projection = DirectX::XMLoadFloat4x4(&matrix);
}
HLSL Portal Cutout Shader
float4x4 gWorldViewProj : WORLDVIEWPROJECTION; 
Texture2D gTexture;

SamplerState samLinear
{
    Filter = MIN_MAG_MIP_LINEAR;
    AddressU = Wrap;// or Mirror or Clamp or Border
    AddressV = Wrap;// or Mirror or Clamp or Border
};

struct VS_INPUT
{
	float3 pos : POSITION;
	float2 texCoord : TEXCOORD;
};

struct VS_OUTPUT
{
	float4 pos : SV_POSITION;
	float2 texCoord : TEXCOORD0;
	float4 screenPos : TEXCOORD1;
};

DepthStencilState EnableDepth
{
	DepthEnable = TRUE;
	DepthWriteMask = ALL;
};

RasterizerState NoCulling
{
	CullMode = NONE;
};

BlendState EnableBlending
{
	BlendEnable[0] = TRUE;
	SrcBlend = SRC_ALPHA;
	DestBlend = INV_SRC_ALPHA;
};


//--------------------------------------------------------------------------------------
// Vertex Shader
//--------------------------------------------------------------------------------------
VS_OUTPUT VS(VS_INPUT input)
{
	VS_OUTPUT output;
	output.pos = mul ( float4(input.pos,1.0f), gWorldViewProj );
	output.texCoord = input.texCoord;
	output.screenPos = output.pos;
	
	return output;
}

//--------------------------------------------------------------------------------------
// Pixel Shader
//--------------------------------------------------------------------------------------
float4 PS(VS_OUTPUT input) : SV_TARGET
{
	float2 ssUV;
	ssUV.x = input.screenPos.x/input.screenPos.w / 2.0f + 0.5f;
	ssUV.y = -input.screenPos.y/input.screenPos.w / 2.0f + 0.5f;
	
	float3 sample = gTexture.Sample(samLinear, ssUV);
	return float4(sample, 1.0f);
}

//--------------------------------------------------------------------------------------
// Technique
//--------------------------------------------------------------------------------------
technique11 Default
{
    pass P0
    {
		SetRasterizerState(NoCulling);
		SetDepthStencilState(EnableDepth, 0);

        SetVertexShader( CompileShader( vs_4_0, VS() ) );
		SetGeometryShader( NULL );
        SetPixelShader( CompileShader( ps_4_0, PS() ) );
    }
}

technique11 TransparencyTech
{
	pass P0
	{
		SetRasterizerState(NoCulling);
		SetDepthStencilState(EnableDepth, 0);
		SetBlendState(EnableBlending, float4(0.0f, 0.0f, 0.0f, 0.0f), 0xFFFFFFFF);

		SetVertexShader(CompileShader(vs_4_0, VS()));
		SetGeometryShader(NULL);
		SetPixelShader(CompileShader(ps_4_0, PS()));
	}
}

Trailer & Gameplay

Contributors & Credits

Overlord Engine

Credits to lecturers Brecht Kets, Matthieu Delaere, and Thomas Goussaert for the base implementation of the Overlord Engine, as well as given math files and the setup of the DirectX window and physics through PhysX.

Main Portal Concept

Credits to Sebastion Lague for providing the main information and hands-on showcase of a Portal implementation in Unity.

Portal Assets

Credits to Sketchfab user T3 and Models-Resource with all credits reserved to Valve.

Textures

Credits to Archive with all credits reserved to Valve.

Portal Font

Credits to FontMeme.

Oblique Projections

Credits to Perry.Cz for a re-explanation for Oblique Projections in DirectX and the original author Eric Lengyel’s version on which the implementation is based.

Lengyel, Eric. “Oblique View Frustum Depth Projection and Clipping”. Journal of Game Development, Vol. 1, No. 2 (2005), Charles River Media, pp. 5–16.

Post Processing (LUT)

Credits to Unreal and Harry Alisavakis.