跳轉到內容

OpenGL 程式設計/迷你傳送門

來自華夏公益教科書

在本系列中,我們將建立一個類似於 Valve 的傳送門遊戲中使用的傳送系統。[1]

傳送門傳送概念

我們想要實現一個傳送裝置,其中源和目的地表示為牆壁上的洞,你可以透過它們看到。進入源傳送門和走出目的地傳送門應該是完全無縫的。也可以將兩個傳送門面對面放置,就像鏡子一樣,以創造無限的深度。

直觀的做法是在 後期處理 中放置第二個攝像機並渲染到紋理。但是,紋理會被扭曲以對映到它們所附加的物體上,特別是當你從側面觀察它時。通常它們看起來像一個平面的電視螢幕[2] - 但我們現在談論的是傳送門!

因為我們想要一個無縫的效果,所以我們將使用不同的方法。場景將被渲染兩次

  • 一次就像玩家已經傳送了,考慮到玩家到傳送門的距離。我們將使用 模板緩衝區 將場景剪下到傳送門的邊界。
  • 一次正常渲染,不覆蓋傳送門,透過欺騙深度緩衝區實現。

為了實現傳送門,我們需要一些先決條件

以下是我們將解決的實現傳送門系統的一些要點

  • 透過傳送門檢視
    • 在遠處矩形中繪製模板
    • 在矩形與攝像機相交的地方繪製模板
  • 碰撞檢測
  • 扭曲
  • 無限/遞迴傳送門顯示(傳送門相互面對)
  • 物體同時出現在兩個位置(複製物體)
  • 最佳化
  • 物理

啟用背面剔除

[編輯 | 編輯原始碼]

繪製傳送門將涉及從另一側傳送門繪製場景,所以為了避免任何問題,讓我們啟用背面剔除,只繪製正面多邊形。

/* main() */
    glEnable(GL_CULL_FACE);

定義傳送門

[編輯 | 編輯原始碼]

所以我們有兩個傳送門

  • 源傳送門(portal1)
  • 目的地傳送門(portal2)

我們將它們表示為經典的網格物體(參見 基礎 教程):一組頂點來定義傳送門的形狀,以及一個物體到世界變換矩陣。

/* 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();

構建新的攝像機

[編輯 | 編輯原始碼]
俯檢視 - 我們想要構建 C'

所以我們需要在傳送門2 位置放置一個新的攝像機,然後向後移動以覆蓋從傳送門1 到原始攝像機的距離。幸運的是,可以透過組合變換矩陣輕鬆完成(記住從後往前讀取矩陣乘法)。

/**
 * 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;
}

我們現在有了新的世界到攝像機 (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 */

你可以以同樣的方式對第二個傳送門進行操作。

保護傳送門場景 - 深度緩衝區

[編輯 | 編輯原始碼]

目前我們只是渲染場景兩次:從傳送門 2 和從主攝像機,但第二次渲染覆蓋了第一次。

訣竅是在深度緩衝區中繪製傳送門,但不要寫入顏色緩衝區:OpenGL 會理解在傳送門上顯示了某些內容,但不會用空白矩形覆蓋它。

我們還注意儲存和恢復之前的顏色/深度配置。

  // 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);

剪下傳送門場景 - 模板緩衝區

[編輯 | 編輯原始碼]
顏色緩衝區和模板緩衝區的兩個連續狀態

乍一看,我們似乎不需要剪下傳送門場景,因為我們無論如何都會覆蓋它的周圍環境,並且深度緩衝區已經保護了傳送門。

但是

  • 這迫使你使用天空盒重寫整個場景背景:你不能再僅僅依靠 glClear(GL_COLOR_BUFFER_BIT) 來清除背景,因為它可能被傳送門檢視寫入,而主場景不會覆蓋那部分。
  • 這沒有最佳化,因為即使對於一小部分傳送門,你也會重新繪製整個螢幕。
  • 最重要的是:當我們顯示兩個傳送門(不僅僅是一個)時,第二個傳送門檢視的深度與第一個的衝突。

即使我們在渲染第二個傳送門檢視時重新繪製第一個傳送門的深度,但這本來是為了在渲染主檢視時保護傳送門 - 第二個傳送門檢視中的物體可能離攝像機更近。因此,深度緩衝區不足以在繪製第二個傳送門時保護第一個傳送門。

有更強大且相關的保護螢幕一部分的方法

  • 剪刀 (矩形剪下)
  • 模板緩衝區 (任意剪下)

因為我們可以從側面觀察傳送門,或者使用矩形以外的形狀繪製它,所以剪刀不夠,所以我們將使用模板緩衝區。

  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-

傳送門碰撞檢測和扭曲

[編輯 | 編輯原始碼]

想法是檢測攝像機移動(一條線)與傳送門(兩個三角形)之間的交點。

所以我們想要編寫一個函式,檢查由兩個點 lalb 定義的直線是否與傳送門相交。

維基百科來幫忙!

我們有一個很好的矩陣可以計算

  • 如果 ,則與平面相交。
  • 如果 ,則交點位於三角形內部。

為了在 C++ 中實現它,請注意,在初始化矩陣時,每個 glm::vec3 都是一個 *列* 向量,因此與數學符號相比,這些值是旋轉的。

我們還需要在比較周圍新增一些小值(1e-6),以確保沒有浮點精度問題。

在檢視座標中工作以簡化方程似乎很有誘惑力,但如果你以後想要傳送物體(一個立方體?:),無論如何你都需要在物體座標中工作。

/**
 * 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;
}

當此測試檢查時,我們會扭曲相機。這很容易,因為我們已經知道如何使用 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]);
  }

基本版本完成!

[編輯 | 編輯原始碼]
迷你傳送門,非遞迴

至此,您擁有一個基本的傳送門,顯示來自兄弟傳送門的檢視,並在觸碰時進行傳送。

但是,根據速度,您可能會在傳送之前看到螢幕閃爍。在下一節中,我們將嘗試瞭解導致此問題的原因以及如何解決它。

參考文獻

[編輯 | 編輯原始碼]
  1. 不要與BSP 傳送門混淆。
  2. 您可以在BlenderNation(指向BlenderArtists.org 帖子 - 需要論壇註冊)上看到使用 Blender 實現的電視式傳送門。

< OpenGL 程式設計

瀏覽並下載完整程式碼
華夏公益教科書