简介
- 备注
- 本示例使用 OpenGL ES 3.0。
本示例的源文件可在 SDK 的 samples\advanced_samples\Terrain* 文件夹中找到。
- 备注
- 为完成目标,本示例涉及多个高级 OpenGL 技术:
- 在顶点着色器中采样纹理
- 实例化绘图
- 常量缓冲区对象
- 像素缓冲区对象
- 2D 纹理阵列li>
- 还应用了视域剔除技术(尽管它纯粹是数学方法)。
表示地形
地形表示取自 Losasso 和 Hoppe 的论文[1].
地形的基本构建块是具有 N 乘以 N(在本实施中为 64 乘以 64)个顶点的棋盘形正方形。棋盘形正方形可以用单个三角形条有效表示
为块生成顶点数据:
GLubyte *pv = vertices; // Block for (unsigned int z = 0; z < size; z++) { for (unsigned int x = 0; x < size; x++) { pv[0] = x; pv[1] = z; pv += 2; } }
为块生成索引缓冲区:
static GLushort *generate_block_indices(GLushort *pi, unsigned int vertex_buffer_offset, unsigned int width, unsigned int height) { // Stamp out triangle strips back and forth. int pos = vertex_buffer_offset; unsigned int strips = height - 1; // After even indices in a strip, always step to next strip. // After odd indices in a strip, step back again and one to the right or left. // Which direction we take depends on which strip we're generating. // This creates a zig-zag pattern. for (unsigned int z = 0; z < strips; z++) { int step_even = width; int step_odd = ((z & 1) ? -1 : 1) - step_even; // We don't need the last odd index. // The first index of the next strip will complete this strip. for (unsigned int x = 0; x < 2 * width - 1; x++) { *pi++ = pos; pos += (x & 1) ? step_odd : step_even; } } // There is no new strip, so complete the block here. *pi++ = pos; // Return updated index buffer pointer. // More explicit than taking reference to pointer. return pi; }
该块位于水平平面(XZ 平面)上,并在网格中拼接在一起以绘制出任意广阔的地形(如上所示)。
为避免绘图走样以及产生过多细节,绘制更远的地形需要较低的分辨率。这可通过二次幂扩展基本 N 乘 N 块来实现。
- 备注
与纹理映射级别相同,裁剪图级别 0 表示具有最高细节的级别。在本示例中,共使用了 10 个裁剪图级别。
在拼接裁剪图时,必须填充出现的小孔。可通过绘制较小的“修正”和/或“修剪”区域完成(如上所示)。还必须在裁剪图级别变化的边缘绘制退化三角形的条。
连接级别 N 和 N + 1 的退化三角形需使用级别 N 的顶点绘制。连接级别 N 和 N + 1 的修剪区域需使用级别 N + 1 的顶点绘制。
必须精心规划块的具体布局以确保无缝完美的地形。需要注意一个重点,即两个相邻的 N 乘 N 块之间的距离为 N – 1。示例代码中看到的大多数偏移量都使用此距离和偶尔的 2 纹素偏移量来表示水平和垂直修正区域的宽度。
在启动时,顶点缓冲区随索引缓冲区一次性上传到 GPU。
使地形贴紧网格
块的位置随摄像头以离散步长移动。以离散步长移动非常重要,因为它可以避免顶点“游移”效果。随着摄像头的移动,裁剪图的较低级别可以更改位置,而较高级别则不会变动,因此用来连接两个裁剪图级别的修剪区域可能需要更改,以便能够填满整个地形。
裁剪图级别偏移量在每一帧上的计算方法如下:
// The clipmap levels only move in steps of texture coordinates. // Computes top-left world position for the levels. vec2 GroundMesh::get_offset_level(const vec2& camera_pos, unsigned int level) { if (level == 0) // Must follow level 1 as trim region is fixed. return get_offset_level(camera_pos, 1) + vec2(size << 1); else { vec2 scaled_pos = camera_pos / vec2(clipmap_scale); // Snap to grid in the appropriate space. // Snap to grid of next level. I.e. we move the clipmap level in steps of two. vec2 snapped_pos = vec_floor(scaled_pos / vec2(1 << (level + 1))) * vec2(1 << (level + 1)); // Apply offset so all levels align up neatly. // If snapped_pos is equal for all levels, // this causes top-left vertex of level N to always align up perfectly with top-left interior corner of level N + 1. // This gives us a bottom-right trim region. // Due to the flooring, snapped_pos might not always be equal for all levels. // The flooring has the property that snapped_pos for level N + 1 is less-or-equal snapped_pos for level N. // If less, the final position of level N + 1 will be offset by -2 ^ N, which can be compensated for with changing trim-region to top-left. vec2 pos = snapped_pos - vec2((2 * (size - 1)) << level); return pos; } }
裁剪图级别中的块偏移量与裁剪图级别偏移量有关。
在顶点着色器中采样纹理
OpenGL ES 3.0 增加了对顶点着色器中进行纹理采样的可靠支持。这使应用程序能够动态地更新顶点数据,而旧方法执行此操作则非常昂贵。顶点缓冲区是固定的,绝不需要更新。(请参见 advanced_samples/Terrain/jni/shaders.h)
虽然顶点缓冲区代表水平平面内的固定网格结构,垂直 Y 分量则为动态且会从高度图纹理中采样。
- 备注
- 不能在顶点着色器(无导数)中使用自动纹理映射。如果要从纹理映射的纹理中采样,则必须通过使用textureLod等提供明确的细节级别。然而,本示例中未使用纹理映射的纹理,因此直接使用texture 是安全的。
高度图表示
每个裁剪图级别均由它自己的 255×255 纹理提供支持。由于每个级别的大小相同,使用由 OpenGL ES 3.0 推出的 2D 纹理阵列功能非常方便且有效。如果使用纹理阵列,则无需绑定不同的纹理来绘制不同的裁剪图级别,因而降低了所需的绘制调用数。
GL_CHECK(glGenTextures(1, &texture)); GL_CHECK(glBindTexture(GL_TEXTURE_2D_ARRAY, texture)); // Use half-float as we don't need full float precision. // GL_RG16UI would work as well as we don't need texture filtering. // 8-bit does not give sufficient precision except for low-detail heightmaps. // Use two components to allow storing current level's height as well as the height of the next level. GL_CHECK(glTexStorage3D(GL_TEXTURE_2D_ARRAY, 1, GL_RG16F, size, size, levels)); GL_CHECK(glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MAG_FILTER, GL_NEAREST)); GL_CHECK(glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_MIN_FILTER, GL_NEAREST)); // The repeat is crucial here. This allows us to update small sections of the texture when moving the camera. GL_CHECK(glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_S, GL_REPEAT)); GL_CHECK(glTexParameteri(GL_TEXTURE_2D_ARRAY, GL_TEXTURE_WRAP_T, GL_REPEAT)); GL_CHECK(glBindTexture(GL_TEXTURE_2D_ARRAY, 0));
高度图更新
使用裁剪图方法时,LOD 中一次只能看见部分地形。由于摄像头四处移动,必须在高度图纹理中更新新的区域,这样才能提供无缝、不中断的地形假象。
可通过从预先计算的高度图上传新数据来更新高度图,或使用具有着色器的帧缓冲区对象逐步更新高度图。
void Heightmap::update_region(vec2 *buffer, unsigned int& pixel_offset, int tex_x, int tex_y, int width, int height, int start_x, int start_y, int level) { if (width == 0 || height == 0) return; // Here we could either stream a "real" heightmap, or generate it procedurally on the GPU by rendering to these regions. buffer += pixel_offset; for (int y = 0; y < height; y++) for (int x = 0; x < width; x++) buffer[y * width + x] = compute_heightmap(start_x + x, start_y + y, level); UploadInfo info; info.x = tex_x; info.y = tex_y; info.width = width; info.height = height; info.level = level; info.offset = pixel_offset * sizeof(vec2); upload_info.push_back(info); pixel_offset += width * height; }
该样品通过从预先计算的 1024×1024 缓冲区(由带通滤波白噪声生成)复制样本,实施了高度图更新。该副本通过使用像素缓冲区对象异步完成。
该裁剪图渲染代码使用 GL_REPEAT 纹理环绕功能,以确保每次摄像头移动时只需更新一小部分的纹理。
重复预先计算的高度图,以使地形无穷远。
- 备注
- 除高度图以外,还通常使用对应的法线贴图。为清楚起见,已省略其内容。法线贴图可以通过采样高度图在顶点着色器中即时计算,也可与高度图一起更新。本示例中的片段着色器根据顶点的高度分配颜色。
高度图混合
裁剪图的分辨率会突然更改级别之间的分辨率。这种细节的不连续性会造成失真。为避免这种情况,两个高度图级别(当前级别和下一个级别)会在顶点着色器中进行采样并混合。
为避免从下一个裁剪图级别过滤高度图值,过滤版高度图会进行预先计算并随附在当前级别的高度中。
// Compute the height at texel (x, y) for cliplevel. // Also compute the sample for the lower resolution (with simple bilinear). // This avoids an extra texture lookup in vertex shader, avoids complex offseting and having to use GL_LINEAR. vec2 Heightmap::compute_heightmap(int x, int y, int level) { float height = sample_heightmap(x << level, y << level); float heights[2][2]; for (int j = 0; j < 2; j++) for (int i = 0; i < 2; i++) heights[j][i] = sample_heightmap(((x + i) & ~1) << level, ((y + j) & ~1) << level); return vec2( height, (heights[0][0] + heights[0][1] + heights[1][0] + heights[1][1]) * 0.25f); }
视域剔除
绘制地形时,大部分地形将不可见。为提高性能,避免绘制从不显示的块非常重要。
本地形示例根据轴对齐的边界性框体实施简单的视域剔除。
该视域剔除实施的想法是将摄像机视域的所有平面表示为平面方程。在测试轴对齐框体的可见性时,我们会一次针对一个视域平面来检查边界性框体的每一个角。
bool Frustum::intersects_aabb(const AABB& aabb) const { // If all corners of an axis-aligned bounding box are on the "wrong side" (negative distance) // of at least one of the frustum planes, we can safely cull the mesh. vec4 corners[8]; for (unsigned int c = 0; c < 8; c++) { // Require 4-dimensional coordinates for plane equations. corners[c] = vec4(aabb.corner(c), 1.0f); } for (unsigned int p = 0; p < 6; p++) { bool inside_plane = false; for (unsigned int c = 0; c < 8; c++) { // If dot product > 0, we're "inside" the frustum plane, // otherwise, outside. if (vec_dot(corners[c], planes[p]) > 0.0f) { inside_plane = true; break; } } if (!inside_plane) return false; } return true; }
如果边界性框体的每个角均在平面的“错误”一侧(负距离),即可证明包含在框体内的网格将永远不用绘制。因此,如果我们能够证明至少有一个视域平面不可见,即可剔除该网格。
为了在世界空间中获取视域的平面方程,完成从剪贴空间的反转变换。
Frustum::Frustum(const mat4& view_projection) { // Frustum planes are in world space. mat4 inv = mat_inverse(view_projection); // Get world-space coordinates for clip-space bounds. vec4 lbn = inv * vec4(-1, -1, -1, 1); vec4 ltn = inv * vec4(-1, 1, -1, 1); vec4 lbf = inv * vec4(-1, -1, 1, 1); vec4 rbn = inv * vec4( 1, -1, -1, 1); vec4 rtn = inv * vec4( 1, 1, -1, 1); vec4 rbf = inv * vec4( 1, -1, 1, 1); vec4 rtf = inv * vec4( 1, 1, 1, 1); // Divide by w. vec3 lbn_pos = vec_project(lbn); vec3 ltn_pos = vec_project(ltn); vec3 lbf_pos = vec_project(lbf); vec3 rbn_pos = vec_project(rbn); vec3 rtn_pos = vec_project(rtn); vec3 rbf_pos = vec_project(rbf); vec3 rtf_pos = vec_project(rtf); // Get plane normals for all sides of frustum. vec3 left_normal = vec_normalize(vec_cross(lbf_pos - lbn_pos, ltn_pos - lbn_pos)); vec3 right_normal = vec_normalize(vec_cross(rtn_pos - rbn_pos, rbf_pos - rbn_pos)); vec3 top_normal = vec_normalize(vec_cross(ltn_pos - rtn_pos, rtf_pos - rtn_pos)); vec3 bottom_normal = vec_normalize(vec_cross(rbf_pos - rbn_pos, lbn_pos - rbn_pos)); vec3 near_normal = vec_normalize(vec_cross(ltn_pos - lbn_pos, rbn_pos - lbn_pos)); vec3 far_normal = vec_normalize(vec_cross(rtf_pos - rbf_pos, lbf_pos - rbf_pos)); // Plane equations compactly represent a plane in 3D space. // We want a way to compute the distance to the plane while preserving the sign to know which side we're on. // Let: // O: an arbitrary point on the plane // N: the normal vector for the plane, pointing in the direction // we want to be "positive". // X: Position we want to check. // // Distance D to the plane can now be expressed as a simple operation: // D = dot((X - O), N) = dot(X, N) - dot(O, N) // // We can reduce this to one dot product by assuming that X is four-dimensional (4th component = 1.0). // The normal can be extended to four dimensions as well: // X' = vec4(X, 1.0) // N' = vec4(N, -dot(O, N)) // // The expression now reduces to: D = dot(X', N') planes[0] = vec4(near_normal, -vec_dot(near_normal, lbn_pos)); // Near planes[1] = vec4(far_normal, -vec_dot(far_normal, lbf_pos)); // Far planes[2] = vec4(left_normal, -vec_dot(left_normal, lbn_pos)); // Left planes[3] = vec4(right_normal, -vec_dot(right_normal, rbn_pos)); // Right planes[4] = vec4(top_normal, -vec_dot(top_normal, ltn_pos)); // Top planes[5] = vec4(bottom_normal, -vec_dot(bottom_normal, lbn_pos)); // Bottom }
使用统一的缓冲区
OpenGL ES 3.0 引入了将常量数据传递到着色器的新方式。在常量数据更改时无需多次调用 glUniform*,可以采用由常规 OpenGL 缓冲区对象支持常量数据的做法。
此做法 (GLSL) 一个非常简单的示例是:
#version 300 es layout(std140) uniform; // Use std140 packing rules for uniform blocks. in vec4 aVertex; uniform VertexData { mat4 viewProjection; // Let the view-projection matrix be backed a buffer object. } vertex; void main() { gl_Position = vertex.viewProjection * aVertex; }
- 备注
- 需要注意的是,虽然常量缓冲区数据通过名称 vertex进行访问, 从而与其他着色器和/或 OpenGL 进行通信,实际使用的是块名称VertexData。
可以使用着色器内的多个统一块,这就需要依据索引来访问统一块。
链接着色器程序之后,可以使用以下命令查询统一块:
GLuint block = glGetUniformBlockIndex(program, "VertexData"); // Note the use of VertexData and not vertex.
现在,我们要指定常量缓冲区将从哪个源位置抽取数据:
glUniformBlockBinding(program, block, 0);
常量缓冲区现在从绑定 0 获取源数据。要绑定供着色器使用的缓冲区,需要使用新的索引版b>glBindBuffer.
glBindBufferBase(GL_UNIFORM_BUFFER, 0, buffer_object); // Binds the entire object. glBindBufferRange(GL_UNIFORM_BUFFER, 0, buffer_object, offset, size); // Binds a range of the object.
- 备注
- 这些新调用仅设置着色器的状态。如果缓冲区用于上传数据等常规 OpenGL 调用,则仍使用通用glBindBuffer
-
// Initialize an UBO. glBindBuffer(GL_UNIFORM_BUFFER, buffer_object); glBufferData(GL_UNIFORM_BUFFER, size, NULL, GL_DYNAMIC_DRAW);
- 备注
- glBindBufferRange 需要对偏移量和大小进行一定的校准。要准确查询所需的校准,可以调用 glGetIntegerv(GL_UNIFORM_BUFFER_OFFSET_ALIGNMENT, …)dd>
使用实例化绘图绘制地形
裁剪图包含绘制多次的简单构建块。为了大幅减少所需的绘制调用数,可以使用由 OpenGL ES 3.0 推出的实例化绘图功能来绘制块。
{常量缓冲区对象用于访问顶点着色器中基于实例的数据。在绘制实例化绘图时,内置变量 gl_InstanceID 可供使用, 它可用来访问缓冲区对象中基于实例的数据阵列:render_draw_list()
void GroundMesh::render_draw_list() { for (std::vector<DrawInfo>::const_iterator itr = draw_list.begin(); itr != draw_list.end(); ++itr) { if (!itr->instances) continue; // Bind uniform buffer at correct offset. GL_CHECK(glBindBufferRange(GL_UNIFORM_BUFFER, 0, uniform_buffer, itr->uniform_buffer_offset, realign_offset(itr->instances * sizeof(InstanceData), uniform_buffer_align))); // Draw all instances. GL_CHECK(glDrawElementsInstanced(GL_TRIANGLE_STRIP, itr->indices, GL_UNSIGNED_SHORT, reinterpret_cast<const GLvoid*>(itr->index_buffer_offset * sizeof(GLushort)), itr->instances)); } }
void GroundMesh::render() { // Create a draw-list. update_draw_list(); // Explicitly bind and unbind GL state to ensure clarity. GL_CHECK(glBindVertexArray(vertex_array)); render_draw_list(); GL_CHECK(glBindVertexArray(0)); GL_CHECK(glBindBuffer(GL_UNIFORM_BUFFER, 0)); GL_CHECK(glBindBufferBase(GL_UNIFORM_BUFFER, 0, 0)); }
参考资料
[1] http://research.microsoft.com/en-us/um/people/hoppe/geomclipmap.pdf