Quantcast
Channel: Mali Developer Center
Viewing all articles
Browse latest Browse all 125

元球

$
0
0

Metaballs

摘要

本教程演示 GPU 如何通过 OpenGL ES 3.0 的转换反馈功能来渲染有机外形的 3D 对象。所有计算都在 GPU 的着色处理器上实施。曲面三角剖分使用移动立方体算法执行。Phong 模型用于照亮元球对象。3D 纹理提供对着色器中的三维阵列的访问。

简介

此应用程序显示在空间中运动的多个元球。当元球相互距离太近时,它们会平顺地改变各自的球形,直到成为一个有机整体。实现该效果最简单的方法是遵循现实生活并使用带电球体的运动。根据物理定律,带电球体的周围空间将具有电场。由于空间中每个点所在的场只有一个值,它通常被称为标量场。空间中各点的标量场值可以简单计算(重力场是标量场的另一个例子,使用类似公式进行计算,但用到的是球体的重量)。

为了形象地显示标量场,我们以一个平面来表示。这通常可以通过选择一些值级别并在空间中显示包含具有该所选级别的所有点的平面来实现。这样的平面通常称为等值面。该级别值通常根据视觉质量凭经验选择。

我们需要考虑用来绘制 3D 对象的图形系统的能力。OpenGL ES 可以轻松渲染 3D 对象,但 OpenGL ES 接受的有效显示表面的唯一原语是三角形。因此,我们分割模型表面并使其对 OpenGL ES 而言代表一组三角形。我们必须在此示例中取近似表示,因为数学上定义的等值面是光滑的,而三角形构成的表面却并非如此。我们使用移动立方体算法来近似表示表面形状。

为使移动立方体算法起作用,我们对空间进行采样并定义等值面值。移动立方体一次在八个样本上运行(每个晶胞角各一个),它们形成基本的立方晶胞。请注意,本示例使用 OpenGL ES3.0 中提供的功能。为使这些功能在您的设备上运行,您必须至少使用 Android 4.3 或 API 第 18 版。

阶段

和处理 3D 图形的大多数应用一样,本示例由几个部分组成。元球项目由五个部分构成:一个在 CPU 上运行的控制程序和四个在 GPU 上运行的补充着色器程序。在 GPU 上运行的程序通常包括两部分:顶点着色器和片段着色器。我们将在本示例中使用八个着色器(每个阶段两个),但只有五个着色器包含实际的程序代码;其余三个着色器是程序对象链接所需的虚拟着色器。

我们把应用分为以下几个阶段:

  1. Calculation of sphere positions  计算球体位置,此阶段更新空间中当前时间的球体位置。在本示例中我们首次使用转换反馈模式。
  2. Scalar field generation 生成标量场,此阶段将模型空间分成 samples_per_axis3 样本并计算空间中各点的标量场值。
  3. Marching Cubes cell-spelling 晶胞式分裂移动立方体,此阶段将标量场分为立方晶胞并确定每个晶胞的类型。晶胞类型 确定等值面是否穿过晶胞;如果是,如何以三角形近似表示。
  4. Marching Cubes triangle generation and rendering 生成和渲染移动立方体三角形,此阶段为每个晶胞生成对应于晶胞类型的三角形。

前三个阶段会使用转换反馈模式。转换反馈模式使我们能够捕捉由顶点着色器生成的输出并将输出记录到缓冲区对象中。其用法在计算球体位置. 中有详细介绍。渲染循环中的每一帧都会执行所有这四个阶段。此外,每个阶段都需要在控制程序 setupGraphics() 函数中执行的某些初始化操作。

计算球体位置

由于整个场景取决于球体在当前渲染时间的位置,因此我们需要首先更新位置。我们可以将描述球体及其运动等式的信息保存在顶点着色器中,因为进行进一步计算只需要当前的球体位置。这可使我们通过最小化在 CPU 和 GPU 内存之间传输的数据量来提高整体性能。这一阶段 (spheres_updater_vert_shader) ,顶点着色器只需要控制程序的时间值来作为输入,我们把这个时间值申明为一个常量数据:

"/** Current time moment. */\n"
"uniform float time;\n"

要将值从控制程序传递到着色器,在着色器程序被编译和链接后,我们就检索常量数据的位置。对于本阶段,我们只需检索 setupGraphics() 函数中的 time常量的位置:

/* Get input uniform location. */
spheres_updater_uniform_time_id = GL_CHECK(glGetUniformLocation(spheres_updater_program_id, spheres_updater_uniform_time_name));

在渲染循环中,我们使用以下命令将当前时间值传递给顶点着色器:
/* Specify input arguments to vertex shader. */
GL_CHECK(glUniform1f(spheres_updater_uniform_time_id, model_time));

 

现在,我们来看一下顶点着色器在该阶段生成的输出值:

"/** Calculated sphere positions. */\n"
"out vec4 sphere_position;\n"

 

正如其源代码所示,着色器仅输出四个浮点值(打包成 vec4s):三个球体坐标和一个球体重量。要计算 n_spheres 球体的参数,指示 OpenGL ES 运行顶点着色器n_spheres 次。控制程序通过在渲染循环过程中发出以下命令来执行此操作:

/* Run sphere positions calculation. */
GL_CHECK(glDrawArrays(GL_POINTS, 0, n_spheres));

 

由于球体位置的计算过程相互独立,OpenGL ES 可能(而且通常会)在多个顶点处理器上同时针对多个球体运行计算。

捕获由顶点着色器产生的值需要我们在链接程序之前,在控制程序中指定我们感兴趣的varyings(即着色器输出变量):

/* Specify shader varyings (output variables) we are interested in capturing. */
GL_CHECK(glTransformFeedbackVaryings(spheres_updater_program_id, 1, &sphere_position_varying_name, GL_SEPARATE_ATTRIBS));

/* Link the program object. */
GL_CHECK(glLinkProgram(spheres_updater_program_id));

 

在这里,我们指定感兴趣的一个值,它在sphere_position_varying_name 常量中有定义。

计算出的每个球体位置被写入到附有控制程序的缓冲区中的适当位置。在 setupGraphics() 函数中,我们需要分配一个缓冲区对象并分配一个存储器来存储由着色器生成的值:

/* Generate buffer object id. Define required storage space sufficient to hold sphere positions data. */
GL_CHECK(glGenBuffers(1, &spheres_updater_sphere_positions_buffer_object_id));
GL_CHECK(glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, spheres_updater_sphere_positions_buffer_object_id));
GL_CHECK(glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, n_spheres * n_sphere_position_components * sizeof(GLfloat), NULL, GL_STATIC_DRAW));
GL_CHECK(glBindBuffer(GL_TRANSFORM_FEEDBACK_BUFFER, 0));

 

接下来,我们分配转换反馈对象 (TFO),并通过将适当的缓冲区对象(此情况中是 spheres_updater_sphere_positions_buffer_object_id) 绑定到 GL_TRANSFORM_FEEDBACK_BUFFER 目标进行配置

/* Generate and bind transform feedback object. */
GL_CHECK(glGenTransformFeedbacks(1, &spheres_updater_transform_feedback_object_id));
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, spheres_updater_transform_feedback_object_id));

/* Bind buffers to store calculated sphere positions. */
GL_CHECK(glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, spheres_updater_sphere_positions_buffer_object_id));
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, 0));

 

在这些命令行中,我们要求 OpenGL ES 在转换反馈模式激活时在缓冲区存储输出值。该 TFO 通过绑定到 GL_TRANSFORM_FEEDBACK 目标得到激活。这正是我们在渲染过程中的第一步操作:

/* Bind buffers to store calculated sphere position values. */
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, spheres_updater_transform_feedback_object_id));

 

之后,我们需要准备转换反馈模式,以用于我们的目的:这一阶段不需要渲染原语,而是要捕捉数据。第一个命令通过丢弃由顶点着色器生成的任何图元,缩短了 OpenGL 管线:

/* Shorten GL pipeline: we will use vertex shader only. */
GL_CHECK(glEnable(GL_RASTERIZER_DISCARD));

 

第二个命令激活转换反馈模式本身,并指示 OpenGL ES 捕捉指定的图元并将其存储到缓冲区对象:

/* Activate transform feedback mode. */
GL_CHECK(glBeginTransformFeedback(GL_POINTS));

 

完成后,我们发出配对命令以停用转换反馈模式:

GL_CHECK(glEndTransformFeedback());

 

并发出另一个命令来恢复 OpenGL ES 管线:

GL_CHECK(glDisable(GL_RASTERIZER_DISCARD));

 

综上所述,这是此阶段的完整列表:

/* 1. Calculate sphere positions stage.
*
* At this stage we calculate new sphere positions in space
* according to current time moment.
*/
/* [Stage 1 Bind buffers to store calculated sphere position values] */
/* Bind buffers to store calculated sphere position values. */
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, spheres_updater_transform_feedback_object_id));
/* [Stage 1 Bind buffers to store calculated sphere position values] */

/* [Stage 1 Enable GL_RASTERIZER_DISCARD] */
/* Shorten GL pipeline: we will use vertex shader only. */
GL_CHECK(glEnable(GL_RASTERIZER_DISCARD));
/* [Stage 1 Enable GL_RASTERIZER_DISCARD] */
{
/* Select program for sphere positions generation stage. */
GL_CHECK(glUseProgram(spheres_updater_program_id));

/* [Stage 1 Specify input arguments to vertex shader] */
/* Specify input arguments to vertex shader. */
GL_CHECK(glUniform1f(spheres_updater_uniform_time_id, model_time));
/* [Stage 1 Specify input arguments to vertex shader] */

/* [Stage 1 Activate transform feedback mode] */
/* Activate transform feedback mode. */
GL_CHECK(glBeginTransformFeedback(GL_POINTS));
/* [Stage 1 Activate transform feedback mode] */
{
/* [Stage 1 Execute n_spheres times vertex shader] */
/* Run sphere positions calculation. */
GL_CHECK(glDrawArrays(GL_POINTS, 0, n_spheres));
/* [Stage 1 Execute n_spheres times vertex shader] */
}
/* [Stage 1 Deactivate transform feedback mode] */
GL_CHECK(glEndTransformFeedback());
/* [Stage 1 Deactivate transform feedback mode] */
}
/* [Stage 1 Disable GL_RASTERIZER_DISCARD] */
GL_CHECK(glDisable(GL_RASTERIZER_DISCARD));
/* [Stage 1 Disable GL_RASTERIZER_DISCARD] */

/* Unbind buffers used at this stage. */
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, 0));
生成标量场

本阶段和第一阶段所用的控制程序存在非常少的差异:

/* 2. Scalar field generation stage.
*
* At this stage we calculate scalar field and store it in buffer
* and later copy from buffer to texture.
*/
/* Bind sphere positions data buffer to GL_UNIFORM_BUFFER. */
GL_CHECK(glBindBuffer(GL_UNIFORM_BUFFER, spheres_updater_sphere_positions_buffer_object_id));

/* Bind buffer object to store calculated scalar field values. */
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, scalar_field_transform_feedback_object_id));

/* Shorten GL pipeline: we will use vertex shader only. */
GL_CHECK(glEnable(GL_RASTERIZER_DISCARD));
{
/* Select program for scalar field generation stage. */
GL_CHECK(glUseProgram(scalar_field_program_id));

/* Activate transform feedback mode. */
GL_CHECK(glBeginTransformFeedback(GL_POINTS));
{
/* Run scalar field calculation for all vertices in space. */
GL_CHECK(glDrawArrays(GL_POINTS, 0, samples_in_3d_space));
}
GL_CHECK(glEndTransformFeedback());
}
GL_CHECK(glDisable(GL_RASTERIZER_DISCARD));

/* Unbind buffers used at this stage. */
GL_CHECK(glBindTransformFeedback(GL_TRANSFORM_FEEDBACK, 0));

 

虽然我们使用不同的程序、缓冲区和转换反馈对象,但是控制程序中的一般操作流程与第一阶段相同。这是因为在此阶段,我们再次使用转换反馈机制,只需向执行实际工作的顶点着色器指定输入和输出变量。唯一不同的命令是第一个命令,其将缓冲区对象绑定到 GL_UNIFORM_BUFFER 目标。通过发出该命令,第一阶段时在缓冲区对象中生成和收集的球体位置可用于本阶段的顶点着色器中。遗憾的是,由于 OpenGL ES 的局限性,只有相对少量的数据可以通过该机制传递到着色器。还值得注意的是,我们指示 OpenGL ES 运行着色器程序 samples_in_3d_space 次,这将在下面说明。

主要区别在该阶段的顶点着色器中,我们将在查看该阶段的想法后进行研讨。

如您所知,质量为 M 的某个对象在距离为 D 的空间中的某一点上所生成的重力场值,与该质量和该距离的平方直接相关,即 FV=M/(D2)。为了解该公式的物理意义,我们还需要引入一个常量系数,但是由于本示例的目的,可将其省略。多个对象生成的重力场值计算为所有场值的总和,即 FVtotal=ΣFVi。在本示例中,我们将使用术语“重量”而不是“质量”;虽然“重量”不如“质量”正确,但是通常比较容易理解。

数学定义的表面很难处理,因此正如许多其他学科,我们通过采样空间来简化任务。我们为每个轴使用 tesselation_level=32 个样本,它在应该计算标量场值的空间中提供 32*32*32 或 32768 个点。您可以尝试增加该值,以提高所生成图像的质量。

如果查看上面的公式,您可能会注意到标量场值只依赖于所有球体的位置和质量(重量)。这可使我们在不同的着色器程序中同时计算空间中多个点的场值。

作为输入数据,顶点着色器需要每个轴上的样本数量和存储在缓冲区中的球体位置:

"/* Uniforms: */\n"
"/** Amount of samples taken for each axis of a scalar field; */\n"
"uniform int samples_per_axis;\n"
"\n"
"/** Uniform block encapsulating sphere locations. */\n"
"uniform spheres_uniform_block\n"
"{\n"
" vec4 input_spheres[N_SPHERES];\n"
"};\n"

 

如您所见,存储在缓冲区的球体数量通过预处理器定义在这里进行硬编码。这是着色器的局限性:它们需要知道在传入常量缓冲区中的确切项目数。如果您希望着色器更加灵活,可以在缓冲区保留更多空间,并根据某些标志或通过验证某个值(例如零重量值)来忽略项目。另一种方法是将纹理用作数据输入阵列,我们将在生成标量场中用到。

该阶段中运行的每个顶点着色器通过对运行着色器的空间中的点进行解码来执行操作:

"/** Decode coordinates in space from vertex number.\n"
" * Assume 3D space of samples_per_axis length for each axis and following encoding:\n"
" * encoded_position = x + y * samples_per_axis + z * samples_per_axis * samples_per_axis\n"
" *\n"
" * @param vertex_index Encoded vertex position\n"
" * @return Coordinates of a vertex in space ranged [0 .. samples_per_axis-1]\n"
" */\n"
"ivec3 decode_space_position(in int vertex_index)\n"
"{\n"
" int encoded_position = vertex_index;\n"
" ivec3 space_position;\n"
"\n"
" /* Calculate coordinates from vertex number. */\n"
" space_position.x = encoded_position % samples_per_axis;\n"
" encoded_position = encoded_position / samples_per_axis;\n"
"\n"
" space_position.y = encoded_position % samples_per_axis;\n"
" encoded_position = encoded_position / samples_per_axis;\n"
"\n"
" space_position.z = encoded_position;\n"
"\n"
" return space_position;\n"
"}\n"

 

这是使用相当广泛的技术,其中内置的OpenGL ES Shading Language 变量 gl_VertexID 用作隐式参数。 每个执行的顶点着色器均具有唯一的值,该值通过 gl_VertexID 传递,且值范围从零到传递给 glDrawArrays 调用的 count 参数的值。 根据 gl_VertexID 值,着色器可以适当控制自己的执行。在该函数中,着色器将 gl_VertexID 的值分成空间中点的 x、y、z 坐标(着色器应为其计算标量场值)。

为了使标量场值独立于 tesselation_level, 着色器将空间坐标中的点归一化为范围 [0..1]:

"vec3 normalize_space_position_coordinates(in ivec3 space_position)\n"
"{\n"
" vec3 normalized_space_position = vec3(space_position) / float(samples_per_axis - 1);\n"
"\n"
" return normalized_space_position;\n"
"}\n"

 

然后,着色器计算标量场值:

"float calculate_scalar_field_value(in vec3 position)\n"
"{\n"
" float field_value = 0.0f;\n"
"\n"
" /* Field value in given space position influenced by all spheres. */\n"
" for (int i = 0; i < N_SPHERES; i++)\n"
" {\n"
" vec3 sphere_position = input_spheres[i].xyz;\n"
" float vertex_sphere_distance = length(distance(sphere_position, position));\n"
"\n"
" /* Field value is a sum of all spheres fields in a given space position.\n"
" * Sphere weight (or charge) is stored in w-coordinate.\n"
" */\n"
" field_value += input_spheres[i].w / pow(max(EPSILON, vertex_sphere_distance), 2.0);\n"
" }\n"
"\n"
" return field_value;\n"
"}\n"

 

最后,着色器通过使用着色器声明的唯一输出变量,输出所计算的标量场值:

"/* Output data: */\n"
"/** Calculated scalar field value. */\n"
"out float scalar_field_value;\n"

 

该阶段结束时,我们已为空间中的每个点计算了标量场值并存储在缓冲区对象中。遗憾的是,大型缓冲区对象无法用作输入数据。因此,在该阶段的结尾,我们需要执行另一项操作:应用另一种普遍的 OpenGL ES 技术,将数据从缓冲区对象传输到纹理中。在控制程序中执行此操作:

GL_CHECK(glActiveTexture(GL_TEXTURE1));
GL_CHECK(glBindBuffer (GL_PIXEL_UNPACK_BUFFER, scalar_field_buffer_object_id));
GL_CHECK(glTexSubImage3D(GL_TEXTURE_3D, /* Use texture bound to GL_TEXTURE_3D */
0, /* Base image level */
0, /* From the texture origin */
0, /* From the texture origin */
0, /* From the texture origin */
samples_per_axis, /* Texture have same width as scalar field in buffer */
samples_per_axis, /* Texture have same height as scalar field in buffer */
samples_per_axis, /* Texture have same depth as scalar field in buffer */
GL_RED, /* Scalar field gathered in buffer has only one component */
GL_FLOAT, /* Scalar field gathered in buffer is of float type */
NULL /* Scalar field gathered in buffer bound to GL_PIXEL_UNPACK_BUFFER target */
));

 

上面的第一个命令激活纹理单元一,第二个命令指定源数据缓冲区,而第三个命令则在特定绑定点执行从缓冲区对象到当前绑定到 GL_TEXTURE_3D 目标的纹理对象的实际数据传输。3D 纹理可用作便捷的访问方式,用以访问在下一个着色器阶段中作为 3D 阵列的数据。

创建并初始化绑定到纹理单元一的 GL_TEXTURE_3D 目标点的纹理,使其在 setupGraphics() 函数中包含适当数量的标量场值:

/* Generate texture object to hold scalar field data. */
GL_CHECK(glGenTextures(1, &scalar_field_texture_object_id));

/* Scalar field uses GL_TEXTURE_3D target of texture unit 1. */
GL_CHECK(glActiveTexture(GL_TEXTURE1));
GL_CHECK(glBindTexture(GL_TEXTURE_3D, scalar_field_texture_object_id));

/* Prepare texture storage for scalar field values. */
GL_CHECK(glTexStorage3D(GL_TEXTURE_3D, 1, GL_R32F, samples_per_axis, samples_per_axis, samples_per_axis));
晶胞式分裂移动立方体

最后两个阶段实际上实施移动立方体算法。该算法是创建 3D 标量场等值面的多边形表面表示的一个方法 [1], [2]。该算法将标量场分为立方晶胞。每个晶胞角有一个标量场值。通过在晶胞角中执行等值面级别和标量场值之间的简单比较操作,可以确定该角是否低于等值面。如果晶胞的一个角位于等值面之下而同一晶胞边缘的另一角位于等值面之上,则很明显等值面与边缘相交。另外,由于场具有连续性和平滑性,同一晶胞中会有其他相交的边缘。使用边缘交叉点可以生成最逼近等值面的多边形。现在,让我们从另一个方面看看该算法。每个晶胞角的状态可以表示为一个布尔值:角或者位于等值面之上(相应位设置为 1),或者相反(相应位设置为 0)。很明显,一个晶胞有八个角,我们得到 28,即角位于等值面之上或之下有 256 种情形。如果表面直接穿过角,则我们假设该角位于等值面之下以保持状态的二进制格式。换句话说,该等值面可以 256 种方法的任一种穿过晶胞。因此,有了这 256 种晶胞类型,我们可以描述要生成的一组三角形以使晶胞近似等值面。

在该阶段,我们按晶胞划分空间并确定晶胞类型。该空间已在前一阶段(生成标量场)中被样品划分,而着色器在晶胞角中需要有八个样品才能形成晶胞。着色器使用等值面级别值并根据上述算法,确定每个晶胞的晶胞类型。

控制程序的第三阶段与第二阶段更为接近,因此不做全面讲解。但是,我们要特别介绍 setupGraphics() 函数中的纹理创建:

/* Generate a texture object to hold cell type data. (We will explain why the texture later). */
GL_CHECK(glGenTextures(1, &marching_cubes_cells_types_texture_object_id));
/* Marching cubes cell type data uses GL_TEXTURE_3D target of texture unit 2. */
GL_CHECK(glActiveTexture(GL_TEXTURE2));
GL_CHECK(glBindTexture(GL_TEXTURE_3D, marching_cubes_cells_types_texture_object_id));
/* Prepare texture storage for marching cube cell type data. */
GL_CHECK(glTexStorage3D(GL_TEXTURE_3D, 1, GL_R32I, cells_per_axis, cells_per_axis, cells_per_axis));
Notice that here we use texture unit two and create a 3D texture of one-component integers (GL_R32I), which will hold types for each of the 31*31*31 cells. The number of cells on each axis (cells_per_axis) is one less than the number of samples on each axis (samples_per_axis). It is easier to explain why using an example: let's imagine that we cut a butter brick into two halves (cells). Parallel to the cut plane we have three sides for two cells (one is shared by both halves). Thus the number of cells is one less than the number of sides. The control program instructs the GPU to execute the vertex shader for each cell:

/* Run Marching Cubes algorithm cell splitting stage for all cells. */
GL_CHECK(glDrawArrays(GL_POINTS, 0, cells_in_3d_space));

在该阶段的顶点着色器中,我们首先以类似于前一阶段的方式解码由着色器的该实例所处理的晶胞的原点在空间中的位置。唯一区别在于,我们在本阶段使用每个轴上的晶胞数而非样本数作为除数:

"ivec3 decode_space_position(in int cell_index)\n"
"{\n"
" ivec3 space_position;\n"
" int encoded_position = cell_index;\n"
"\n"
" /* Calculate coordinates from encoded position */\n"
" space_position.x = encoded_position % cells_per_axis;\n"
" encoded_position = encoded_position / cells_per_axis;\n"
"\n"
" space_position.y = encoded_position % cells_per_axis;\n"
" encoded_position = encoded_position / cells_per_axis;\n"
"\n"
" space_position.z = encoded_position;\n"
"\n"
" return space_position;\n"
"}\n"

然后,着色器通过从上一阶段(生成标量场)创建的纹理中提取标量场值,将当前晶胞角的标量场值收集到阵列中:

" /* Find scalar field values in cell corners. */\n"
" for (int i = 0; i < corners_in_cell; i++)\n"
" {\n"
" /* Calculate cell corner processed at this iteration. */\n"
" ivec3 cell_corner = space_position + cell_corners_offsets[i];\n"
"\n"
" /* Calculate cell corner's actual position ([0.0 .. 1.0] range.) */\n"
" vec3 normalized_cell_corner = vec3(cell_corner) / scalar_field_normalizers;\n"
"\n"
" /* Get scalar field value in cell corner from scalar field texture. */\n"
" scalar_field_in_cell_corners[i] = textureLod(scalar_field, normalized_cell_corner, 0.0).r;\n"
" }\n"

有了晶胞角中的标量场值以及等值面级别值,我们可以确定晶胞类型:

"int get_cell_type_index(in float cell_corner_field_value[8], in float isolevel)\n"
"{\n"
" int cell_type_index = 0;\n"
"\n"
" /* Iterate through all cell corners. */\n"
" for (int i = 0; i < 8; i++)\n"
" {\n"
" /* If corner is inside isosurface then set bit in cell type index index. */\n"
" if (cell_corner_field_value[i] < isolevel)\n"
" {\n"
" /* Set appropriate corner bit in cell type index. */\n"
" cell_type_index |= (1<<i);\n"
" }\n"
" }\n"
"\n"
" return cell_type_index;\n"
"}\n"

晶胞类型由该阶段返回并存储在适当的缓冲区,供下一阶段进一步处理。

生成和渲染移动立方体三角形

移动立方体算法近似于使用多达五个三角形穿过晶胞的表面。晶胞类型 0 和 255 不会产生三角形;基于这一事实,所有晶胞角需低于或高于等值面。利用按晶胞类型索引的查找表 (tri_table) 是获得为晶胞生成的三角形列表的有效方式。

最后,我们具有生成一组三角形并进行渲染所需的所有信息。对于该阶段,控制程序不会激活转换反馈模式,因为无需存储生成的任何三角形顶点。将生成的三角形顶点传递给片段着色器以进行剔除或渲染比较有用:

/* Run triangle generating and rendering program. */
GL_CHECK(glDrawArrays(GL_TRIANGLES, 0, cells_in_3d_space * triangles_per_cell * vertices_per_triangle));

如果看一下常量值,您可能会注意到,我们在这里指示 OpenGL ES 为每个晶胞运行十五次着色器。此乘法器来自移动立方体算法:对于穿过晶胞且与每个边缘相交不超过一次的等值面,可以用多达五个三角形近似该等值面,并且我们使用三个顶点来定义一个三角形。还请注意,该算法不使用晶胞角构建三角形,而是使用晶胞的“中间”点。在这种情况下,“中间”点是晶胞边缘上的点,该边缘实际上与等值面相交。这提供了更好的图像质量。

每个晶胞可以根据其晶胞类型最多生成五个三角形,但大多数晶胞类型会产生数量明显更少的三角形。例如,tri_table 用于从晶胞类型构建三角形。在这里我们提供 256 个晶胞类型的前四个:

const GLint tri_table[mc_cells_types_count * mc_vertices_per_cell] =
  {
  -1, -1, -1,   -1, -1, -1,     -1, -1, -1,   -1, -1, -1,   -1, -1, -1,
    0, 8, 3,    -1, -1, -1,     -1, -1, -1,   -1, -1, -1,   -1, -1, -1,
    0, 1, 9,    -1, -1, -1,     -1, -1, -1,   -1, -1, -1,   -1, -1, -1,
    1, 8, 3,      9, 8, 1,      -1, -1, -1,   -1, -1, -1,   -1, -1, -1,

该表中的每一行代表一个晶胞类型。每种晶胞类型包含多达五个三角形。每个三角形由三个连续的顶点定义。表中的每个数字均是一个晶胞边缘数,其中“中间”点作为三角形的顶点。如果晶胞类型提供的三角形少于五个,则额外的边缘数会使用值 -1 填充。例如,晶胞类型 0(第一个数据行)没有定义任何三角形:其所有“中间”点均设置为 -1。晶胞类型 1(第二个数据行)定义了由晶胞的边缘 0、8 和 3 的“中间”点组成的一个三角形。晶胞类型 2 再次定义了单个三角形,而晶胞类型 3 则定义了两个三角形。细心的读者可能会注意到表中的晶胞边缘数小于 12。这是因为晶胞立方体只有 12 条边,其在表中的编号是从 0 到 11。

每个顶点着色器实例只处理三角形的一个顶点。首先,根据 gl_VertexID,其以处理“生成标量场”的类似方式解码三角形顶点数和晶胞位置:

"ivec4 decode_cell_position(in int encoded_position_argument)\n"
"{\n"
" ivec4 cell_position;\n"
" int encoded_position = encoded_position_argument;\n"
"\n"
" /* Decode combined triangle and vertex number. */\n"
" cell_position.w = encoded_position % mc_vertices_per_cell;\n"
" encoded_position = encoded_position / mc_vertices_per_cell;\n"
"\n"
" /* Decode coordinates from encoded position. */\n"
" cell_position.x = encoded_position % CELLS_PER_AXIS;\n"
" encoded_position = encoded_position / CELLS_PER_AXIS;\n"
"\n"
" cell_position.y = encoded_position % CELLS_PER_AXIS;\n"
" encoded_position = encoded_position / CELLS_PER_AXIS;\n"
"\n"
" cell_position.z = encoded_position;\n"
"\n"
" return cell_position;\n"
"}\n"

在这里,解码多了一个步骤:除晶胞坐标外,还对合并的三角形和顶点数进行解码。我们将此信息存储在 main() 函数的本地变量中,以作进一步处理:

" /* Split gl_vertexID into cell position and vertex number processed by this shader instance. */\n"
" ivec4 cell_position_and_vertex_no = decode_cell_position(gl_VertexID);\n"
" ivec3 cell_position = cell_position_and_vertex_no.xyz;\n"
" int triangle_and_vertex_number = cell_position_and_vertex_no.w;\n"

获得坐标后,着色器可以检索晶胞类型和晶胞边缘数:

" /* Get cell type for cell current vertex belongs to. */\n"
" int cell_type_index = get_cell_type(cell_position);\n"
"\n"
" /* Get edge of the cell to which belongs processed vertex. */\n"
" int edge_number = get_edge_number(cell_type_index, triangle_and_vertex_number);\n"

仅在着色器应该生成一个顶点(即边缘数不等于 -1)时,我们才会从这里继续操作。虚拟三角形顶点的丢弃将稍后考虑。

通过晶胞坐标,我们可以计算晶胞原点的坐标:

" /* Calculate normalized coordinates in space of cell origin corner. */\n"
" vec3 cell_origin_corner = vec3(cell_position) / float(samples_per_axis - 1);\n"

有了晶胞原点的坐标和边缘数(我们目前正在计算的“中间”点),我们可以发现该特定边缘开始和结束的晶胞角顶点:

" /* Calculate start and end edge coordinates. */\n"
" vec3 start_corner = get_edge_coordinates(cell_origin_corner, edge_number, true);\n"
" vec3 end_corner = get_edge_coordinates(cell_origin_corner, edge_number, false);\n"

对于光线,我们计算等值面的法向矢量:

" /* Calculate share of start point of an edge. */\n"
" float start_vertex_portion = get_start_corner_portion(start_corner, end_corner, iso_level);\n"
"\n"
" /* Calculate ''middle'' edge vertex. This vertex is moved closer to start or end vertices of the edge. */\n"
" vec3 edge_middle_vertex = mix(end_corner, start_corner, start_vertex_portion);\n"

现在,有了边缘开始和结束顶点中的标量场值和等值面级别值,我们可以计算该等值面与边缘相交的确切位置:

" /* Calculate normal to surface in the ''middle'' vertex. */\n"
" vec3 vertex_normal = calc_phong_normal(start_vertex_portion, start_corner, end_corner);\n"

位于边缘“中间”点的表面的法向矢量计算为开始和结束边缘顶点中的法向矢量的混合。开始和结束边缘顶点中的法向矢量使用标量场的偏导数来计算,而偏导数使用与顶点标量场值相邻的标量场值进行计算。有关计算某个表面的法向矢量的详细信息,请查看 [3]。假定您已经熟悉此照明技术,我们将不赘述其效果。

如果发现着色器处理不应该生成的三角形顶点(边缘数是 -1),那么它会通过指定与虚拟三角形的所有其他顶点相匹配的坐标来放弃处理该顶点:

" /* This cell type generates fewer triangles, and this particular one should be discarded. */\n"
" gl_Position = vec4(0); /* Discard vertex by setting its coordinate in infinity. */\n"
" phong_vertex_position = gl_Position;\n"
" phong_vertex_normal_vector = vec3(0);\n"
" phong_vertex_color = vec3(0);\n"

这一阶段使用几何着色器将会更佳;不同于顶点着色器,几何着色器可以根据需要生成所需数量的顶点,或者根本不生成。然而,几何着色器属于 OpenGL 功能,不存在于核心 OpenGL ES 中,所以我们需要在顶点着色器中进行这项作业,通过向所有顶点坐标指定相同的值丢弃不适当的三角形。

片段着色器实施简单的 Phong 照明模型 [4]。有关更多信息,请查看“生成和渲染移动立方体三角形”阶段的片段着色器中的代码注释。

参考资料

[1] http://paulbourke.net/geometry/polygonise/
[2] http://en.wikipedia.org/wiki/Marching_cubes
[3] http://mathworld.wolfram.com/NormalVector.html
[4] http://en.wikipedia.org/wiki/Phong_shading


Viewing all articles
Browse latest Browse all 125

Trending Articles