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
 
Portal teleportation concept

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

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

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

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
 
Top view - we want to build C'

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

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
 
Color buffer and two successive states of the stencil buffer

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

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
 
Mini-Portal, non-recursive

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
  1. Not to be confused with BSP portals.
  2. You can see a TV-style implementation using Blender presented at BlenderNation (pointing to a BlenderArtists.org post - forum registration needed)

< OpenGL Programming

Browse & download complete code