OpenGL Programming/Mini-Portal
In this series we'll create a teleportation system similar to the one used in Valve's Portal game.[1]
Concept
[edit | edit source]We want to implement a teleportation device, where the source and destination are represented as holes in the walls that you can see through. Going in the source portal and out the destination portal should be completely seamless. It is also possible to place the two portals face to face, like mirrors, to create infinite depth.
The intuitive idea would be to place a second camera and render to texture (as seen in Post-Processing). However textures are distorted to map the object on which they are attached, especially if you look at it sideways. Typically they would look like a flat TV screen[2] - but we're talking about PORTALS here!
Since we want a seamless effect, we'll use a different method. The scene will be rendered twice:
- Once as if the player were already teleported, taking into account the player's distance to the portal. We'll clip the scene to the portal's boundaries using the Stencil buffer.
- Once normally, without overwriting the portal by tricking the depth buffer.
To implement the portal we'll need a few prerequisites:
Overview
[edit | edit source]Here's the points that we'll address to implement our portal system:
- View through portal
- Stencil draw in a distant rectangle
- Stencil draw in rectangle intersection with camera
- Collision detection
- Warp
- Infinite/recursive portals display (portals facing each others)
- Object at 2 locations at the same time (duplicate objects)
- Optimization
- Physics
Enabling back-face culling
[edit | edit source]Drawing the portal will involve drawing the scene from behind the other portal, so to avoid any issue let's enable back-face culling, to draw front-facing polygons only:
/* main() */
glEnable(GL_CULL_FACE);
Defining the portals
[edit | edit source]So we have two portals:
- the source portal (portal1)
- the destination portal (portal2)
We'll represent them as classic Mesh objects (see the Basics tutorials): a set of vertices to define the portal shape, and an object2world transformation matrix:
/* Global */
Mesh portals[2];
/* init_resources() */
glm::vec4 portal_vertices[] = {
glm::vec4(-1, -1, 0, 1),
glm::vec4( 1, -1, 0, 1),
glm::vec4(-1, 1, 0, 1),
glm::vec4( 1, 1, 0, 1),
};
for (unsigned int i = 0; i < sizeof(portal_vertices)/sizeof(portal_vertices[0]); i++) {
portals[0].vertices.push_back(portal_vertices[i]);
portals[1].vertices.push_back(portal_vertices[i]);
}
GLushort portal_elements[] = {
0,1,2, 2,1,3,
};
for (unsigned int i = 0; i < sizeof(portal_elements)/sizeof(portal_elements[0]); i++) {
portals[0].elements.push_back(portal_elements[i]);
portals[1].elements.push_back(portal_elements[i]);
}
// 90° angle + slightly higher
portals[0].object2world = glm::translate(glm::mat4(1), glm::vec3(0, 1, -2));
portals[1].object2world = glm::rotate(glm::mat4(1), -90.0f, glm::vec3(0, 1, 0))
* glm::translate(glm::mat4(1), glm::vec3(0, 1.2, -2));
portals[0].upload();
portals[1].upload();
Building a new camera
[edit | edit source]So we need to position a new camera at the portal2 position, and then go backwards to cover the distance from portal1 to the original camera. Fortunately, it can be done easily by combining the transformation matrices (remember to read matrix multiplications backwards):
/**
* Compute a world2camera view matrix to see from portal 'dst', given
* the original view and the 'src' portal position.
*/
glm::mat4 portal_view(glm::mat4 orig_view, Mesh* src, Mesh* dst) {
glm::mat4 mv = orig_view * src->object2world;
glm::mat4 portal_cam =
// 3. transformation from source portal to the camera - it's the
// first portal's ModelView matrix:
mv
// 2. object is front-facing, the camera is facing the other way:
* glm::rotate(glm::mat4(1.0), glm::radians(180.0f), glm::vec3(0.0,1.0,0.0))
// 1. go the destination portal; using inverse, because camera
// transformations are reversed compared to object
// transformations:
* glm::inverse(dst->object2world)
;
return portal_cam;
}
We now have a new world2camera (View) matrix. We can pass it to our shaders to make them render the scene from this new point of view:
/* onDisplay */
glm::mat4 portal_view = portal_view(transforms[MODE_CAMERA], &portals[0], &portals[1]);
glUniformMatrix4fv(uniform_v, 1, GL_FALSE, v);
glUniformMatrix4fv(uniform_v_inv, 1, GL_FALSE, glm::value_ptr(glm::inverse(portal_view)));
main_object.draw();
ground.draw();
...
/* then reset the view and re-draw the scene as usual */
You can do it for the second portal the same way.
Protecting the portal scene - depth buffer
[edit | edit source]Currently we just render the scene twice: from portal2 and from the main camera, but the second rendering overwrites the first.
The trick is to draw the portal in the depth buffer, but without writing it to the colors buffer: OpenGL will understand that something is displayed on the portal, but will not overwrite it with a blank rectangle.
We also take care of saving and restoring the previous color/depth configuration:
// Draw portal in the depth buffer so they are not overwritten
glClear(GL_DEPTH_BUFFER_BIT);
GLboolean save_color_mask[4];
GLboolean save_depth_mask;
glGetBooleanv(GL_COLOR_WRITEMASK, save_color_mask);
glGetBooleanv(GL_DEPTH_WRITEMASK, &save_depth_mask);
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glDepthMask(GL_TRUE);
for (int i = 0; i < 2; i++)
portals[i].draw();
glColorMask(save_color_mask[0], save_color_mask[1], save_color_mask[2], save_color_mask[3]);
glDepthMask(save_depth_mask);
Clipping the portal scene - stencil buffer
[edit | edit source]At first glance, it seems we do not need to clip the portal scene, since we're overwriting its surroundings anyway, and the depth buffer protects the portal already.
However:
- This forces you to rewrite all the scene background with a skybox: you cannot rely on
glClear(GL_COLOR_BUFFER_BIT)
alone anymore to clear the background, since it may be written to by a portal view, and the main scene won't overwrite that part. - This is not optimized, since you redraw the full screen even for a tiny bit of portal.
- And above all: when we display the two portals (not just one), the second portal view's depth conflicts with the first's.
Even if we redrew the first portal's depth while rendering the second portal's view, this was meant to protect the portal when rendering the main view - the second portal view may have objects much closer to the camera. Consequently, the depth buffer is not enough to protect the first portal when drawing the second one.
There are more powerful and relevant ways to protect a part of the screen:
- the scissors (rectangle clipping)
- the stencil buffer (arbitrary clipping)
Since we can look at our portal sideway, or draw it using another shape than a rectangle, the scissors are not enough, so we'll use the stencil buffer.
glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);
glDepthMask(GL_FALSE);
glStencilFunc(GL_NEVER, 0, 0xFF);
glStencilOp(GL_INCR, GL_KEEP, GL_KEEP); // draw 1s on test fail (always)
// draw stencil pattern
glClear(GL_STENCIL_BUFFER_BIT); // needs mask=0xFF
glUniformMatrix4fv(uniform_v, 1, GL_FALSE, transforms[MODE_CAMERA]);
portal->draw();
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glDepthMask(GL_TRUE);
glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);
/* Fill 1 or more */
glStencilFunc(GL_LEQUAL, 1, 0xFF);
glUniformMatrix4fv(uniform_v, 1, GL_FALSE, portal_view);
// -Ready to draw main scene-
Portal collision detection and warp
[edit | edit source]The idea is to detect the intersection between a camera move (a line) and the portal (two triangles).
So we want to write a function that checks whether the line defined by two points la
and lb
intersect the portal.
Wikipedia to the rescue!
We have a nice matrix ready to compute:
- If , then there's an intersection with the plane.
- If , then the intersection point is inside the triangle.
To implement it in C++, note that when initializing the matrix, each glm::vec3 is a column vector, so the values are rotated compared to the mathematic notation.
We also need to add some small values (1e-6
) around the comparisons to make sure there's no floating point precision issue.
It's tempting to work in View coordinates to try and simplify the equations, but if you want to teleport objects later on (a cube? :), you'll need to work in object coordinates anyway.
/**
* Checks whether the line defined by two points la and lb intersects
* the portal.
*/
int portal_intersection(glm::vec4 la, glm::vec4 lb, Mesh* portal) {
if (la != lb) { // camera moved
// Check for intersection with each of the portal's 2 front triangles
for (int i = 0; i < 2; i++) {
// Portal coordinates in world view
glm::vec4
p0 = portal->object2world * portal->vertices[portal->elements[i*3+0]],
p1 = portal->object2world * portal->vertices[portal->elements[i*3+1]],
p2 = portal->object2world * portal->vertices[portal->elements[i*3+2]];
// Solve line-plane intersection using parametric form
glm::vec3 tuv =
glm::inverse(glm::mat3(glm::vec3(la.x - lb.x, la.y - lb.y, la.z - lb.z),
glm::vec3(p1.x - p0.x, p1.y - p0.y, p1.z - p0.z),
glm::vec3(p2.x - p0.x, p2.y - p0.y, p2.z - p0.z)))
* glm::vec3(la.x - p0.x, la.y - p0.y, la.z - p0.z);
float t = tuv.x, u = tuv.y, v = tuv.z;
// intersection with the plane
if (t >= 0-1e-6 && t <= 1+1e-6) {
// intersection with the triangle
if (u >= 0-1e-6 && u <= 1+1e-6 && v >= 0-1e-6 && v <= 1+1e-6 && (u + v) <= 1+1e-6) {
return 1;
}
}
}
}
return 0;
}
When this test checks, we warp the camera. This is very easy because we already know how to compute its transformation matrix with portal_view()
!
/* onIdle() */
glm::mat4 prev_cam = transforms[MODE_CAMERA];
// Update camera position depending on keyboard keys
...
/* Handle portals */
// Movement of the camera in world view
for (int i = 0; i < 2; i++) {
glm::vec4 la = glm::inverse(prev_cam) * glm::vec4(0.0, 0.0, 0.0, 1.0);
glm::vec4 lb = glm::inverse(transforms[MODE_CAMERA]) * glm::vec4(0.0, 0.0, 0.0, 1.0);
if (portal_intersection(la, lb, &portals[i]))
transforms[MODE_CAMERA] = portal_view(transforms[MODE_CAMERA], &portals[i], &portals[(i+1)%2]);
}
Basic version done!
[edit | edit source]At this point, you have a basic portal, displaying the view from the brother portal, and teleporting when touched.
However, depending on the speed, you may see the screen flicker just before teleporting. In the next section, we will try to understand what causes this, and how to fix it.
References
[edit | edit source]- ↑ Not to be confused with BSP portals.
- ↑ You can see a TV-style implementation using Blender presented at BlenderNation (pointing to a BlenderArtists.org post - forum registration needed)