【OpenGL ES】图元装配与光栅化

OpenGL ES 3.0支持三种基本图元,三角形、直线和点,它们由一组表示顶点位置的顶点描述,其它如颜色、纹理坐标和几何法线等也作为通用属性与每个顶点关联。

1、三角形

三角形有三种类型,GL_TRIANGLES用于绘制一系列单独的三角形,GL_TRIANGLE_STRIPE用于绘制一系列相互连接的三角形,GL_TRIANGLE_FAN用于绘制一系列扇形连接的三角形,如下图所示,对于一组顶点数据(v0,v1,v2,v3,v4,v5),GL_TRIANGLES绘制两个三角形(v0,v1,v2)和(v3,v4,v5),GL_TRIANGLE_STRIPE绘制三个三角形(v0,v1,v2)、(v2,v1,v3)和(v2,v3,v4),GL_TRIANGLE_FAN绘制三个三角形(v0,v1,v2)、(v0,v2,v3)和(v0,v3,v4)。

【OpenGL ES】图元装配与光栅化_第1张图片

2、直线

直线也有三种类型,分别是绘制不相连线段的GL_LINES、绘制相连线段的GL_LINE_STRIPE和绘制首尾相连线段的GL_LINE_LOOP,如下图所示,顶点数据间的连接顺序比较简单。

【OpenGL ES】图元装配与光栅化_第2张图片

设置线宽使用glLineWidth,默认为1,实际值则由与之最接近的整数而定,线宽有个允许的范围,通过glGetXxx查询GL_ALIASED_LINE_WIDTH_RANGE可得,但这个范围是否支持由实现而定,只有1是保证支持的,查询线宽的值使用GL_LINE_WIDTH。

3、点

点对应的图元类型为GL_POINTS,是指定位置和半径的屏幕对齐的正方形,位置描述正方形的中心,半径用于计算正方形的四个坐标。gl_PointSize是可用于在顶点着色器中输出点半径或者点尺寸的内建变量,受到特定实现所支持的非平滑点尺寸范围的限制,通过glGet查询GL_ALIASED_POINT_SIZE_RANGE可得。默认情况下,OpenGL ES 3.0将窗口原点描述为左下角,但是,对于点,点坐标的原点是左上角。gl_PointCoord是只能在渲染图元为点时用于片段着色器内部的内建变量,它用mediump精度限定符声明为一个vec2变量,随着我们从左侧移到右侧,从顶部移到底部,赋予gl_PointCoord的值从0到1变化,如下图所示。

【OpenGL ES】图元装配与光栅化_第3张图片

4、绘制图元

OpenGL ES 3.0绘制图元包括如下五个API:

void glDrawArrays(GLenum mode,
    GLint first,
    GLsizei count);
void glDrawElements(GLenum mode,
    GLsizei count,
    GLenum type,
    const GLvoid * indices);
void glDrawRangeElements(GLenum mode,
    GLuint start,
    GLuint end,
    GLsizei count,
    GLenum type,
    const GLvoid * indices);
void glDrawArraysInstanced(GLenum mode,
    GLint first,
    GLsizei count,
    GLsizei primcount);
void glDrawElementsInstanced(GLenum mode,
    GLsizei count,
    GLenum type,
    const void * indices,
    GLsizei primcount);

glDrawArrays用元素索引为first到first+count-1的元素指定的顶点绘制mode指定的图元,适用于由一系列顺序元素索引描述的图元其几何形状的顶点不共享。但是,游戏或者其它3D应用程序使用的典型对象由多个三角形网格组成,其中的元素索引可能不一定按照顺序,顶点通常在网格的三角形之间共享,这种情况下使用glDrawElements或glDrawRangeElements更好,其中indices存储了元素索引,type为元素索引的类型,start、end为indices中的最小、最大元素索引。例如,对于一个立方体来说,如果使用glDrawArrays进行绘制,立方体的每一面都要调用glDrawArrays一次,因为立方体的顶点是多个面共享的,所以这些共享顶点必须重复绘制,绘制GL_TRIANGLE_FAN时需要分配24个顶点,绘制GL_TRIANGLES时则需要分配多达36个顶点,而不是实际的8个顶点,这不是一个高效的方法,如果使用glDrawElements进行绘制,程序在GPU上运行的将更快。

OpenGL ES 3.0引入了glDrawArraysInstanced和glDrawElementsInstanced用于几何形状实例化,这种方式很高效,可以用一次API调用多次渲染具有不同属性的同一个对象,在渲染大量类似对象时很有用,降低了向OpenGL ES引擎发送许多API调用的CPU处理开销,其中primcount指定绘制的图元实例数量。可以使用两种方法访问每个实例的数据,一种是使用glVertexAttribDivisor指示OpenGL ES对每个实例读取一次或多次顶点属性,另一种是使用内建输入变量gl_InstanceID作为顶点着色器中的缓冲区索引以访问每个实例的数据。另外,最新的OpenGL ES 3.1及3.2还引入了其它绘图API,这里不作介绍。

绘制图元时,如果没有限定符,那么顶点着色器的输出值在图元中使用线性插值,但是使用平面着色时没有发生插值,所以片段着色器中只有一个顶点值可用,对于给定的图元示例,这个驱动顶点确定使用顶点着色器的哪一个顶点输出,因为只能使用一个顶点,所以驱动顶点的选择有既定的规则,为当前图元对应的最大索引。

绘制图元的另一个概念是图元重启,可以在一次绘图调用中渲染多个不相连的图元,这对于降低绘图API的调用开销是有利的。使用图元重启,可以通过在索引列表中插入一个特殊索引来重启一个用于索引绘图调用的图元,如glDrawElements、glDrawRangeElements或glDrawElementsInstanced,这个特殊索引是该索引类型的最大可能索引,如索引类型为GL_UNSIGNED_BTYE时其值为255。例如,假定两个三角形条带分别有元素索引(0,1,2,3)和(8,9,10,11),如果我们想利用图元重启在一次调用glDrawElements中绘制两个条带,索引类型为GL_UNSIGNED_BTYE,则组合的元素索引列表为(0,1,2,3,255,8,9,10,11),可以用如下代码启用和禁用图元重启。

glEnable(GL_PRIMITIVE_RESTART_FIXED_INDEX);
// draw primitives
glDisable(GL_PRIMITIVE_RESTART_FIXED_INDEX);

5、图元绘制性能

应用程序应该确保用尽可能大的图元尺寸调用glDrawElements或glDrawElementsInstanced,如果绘制GL_TRIANGLES,这很容易做到,但是对于GL_TRIANGLE_STRIPE或GL_TRIANGLE_FAN,则可以用上面提到的图元重启将这些网格连接在一起,而不用对每个三角形网格单独调用glDrawElements。不使用图元重启的另一种方法是添加造成退化三角形的元素索引,代价是使用更多的索引,新添加的索引数量取决于三角形是GL_TRIANGLE_STRIPE还是GL_TRIANGLE_FAN,如果是前者,特别要注意其索引数量及两个图元连接时的索引顺序。需要的附加元素索引数量和生成的退化三角形数量取决于第一个三角形的顶点数量,必须保留下一个连接三角形的弯曲顺序。退化三角形是两个或者更多顶点相同的三角形,GPU可以非常简单地检测和拒绝退化三角形,所以这是很好的性能改进,我们可以将一个很大的图元放入由GPU渲染的队列。

例如,对于两组三角形索引(0,1,2,3)和(8,9,10,11),用一次glDrawElements调用绘制两个GL_TRIANGLE_STRIPE,组合元素索引列表为(0,1,2,3,3,8,8,9,10,11),添加了3和8,绘制的三角形为(0,1,2)、(2,1,3)、(2,3,3)、(3,3,8)、(3,8,8)、(8,8,9)、(8,9,10)和(9,10,11),其中有重复索引的三角形为退化三角形共四个;对于两组三角形索引(0,1,2,3,4)和(8,9,10,11),用一次glDrawElements调用绘制两个GL_TRIANGLE_STRIPE,组合元素索引列表则为(0,1,2,3,4,4,4,8,8,9,10,11),添加了4、4和8,绘制的三角形为(0,1,2)、(2,1,3)、(2,3,4)、(4,3,4)、(4,4,4)、(4,4,8)、(4,8,8)、(8,8,9)、(8,9,10)和(9,10,11),其中有重复索引的三角形为退化三角形共五个。

在确定如何安排图元元素索引时考虑变换后顶点缓存的大小也有助于总体性能的提升,大部分GPU采用一个变换后顶点缓存,在顶点着色器执行由元素索引给出的顶点之前,进行一次检查,以确定顶点是否已经存在于变换后缓存,没有缓存才执行顶点操作,这种技术减少了顶点着色器执行重用顶点的次数。

6、图元装配

图元装配的输入来自顶点着色器的输出,最后输出到光栅化阶段,其中包括三个阶段,裁剪、透视分割和视口变换。在图元装配过程,顶点经历不同的坐标系统,首先以本地坐标空间输入到OpenGL ES,在顶点着色器执行之后,顶点进入裁剪坐标空间,这一坐标变换通过顶点着色器中定义的uniform变量完成,随后经过透视分割进入规范化设备坐标空间,最后经过视口变换进入窗口坐标空间。

裁剪有一个裁剪体或者叫视景体,通过坐标(Xc,Yc,Zc,Wc)指定,由上、下、左、右、远、近共六个平面组成,位于裁剪体之外的图元将被裁剪抛弃,避免处理不必要的图元。需要注意的是,三角形、直线的裁剪在硬件中的执行代价可能很高,部分在x和y平面之外的图元不一定需要裁剪,通过渲染到一个大于glViewport指定的视口尺寸的视口,x和y平面中的裁剪变成剪裁操作Scissor,GPU可以高效地实施剪裁,这个更大的视口区域被称作保护带区域。

透视分割取得裁剪坐标(Xc,Yc,Zc,Wc)指定的点,并将其投影到视口上,每个坐标除以Wc也就是(Xc/Wc,Yc/Wc,Zc/Wc,Wc/Wc),得到规范化的设备坐标(Xd,Yd,Zd),其值位于-1.0到1.0之间,包括边界值。

视口是一个二维矩形窗口区域,是所有OpenGL ES渲染操作最终显示的地方,在视口变换阶段,坐标(Xd,Yd)根据视口的大小将被转换为真正的窗口坐标,Zd根据glDepthRangef指定的near和far深度值将被转换为窗口的Z值。视口变换调用如下的glViewport完成,x和y指定左下角坐标,单位是像素,初始值为(0,0),width和height指定宽和高,初始值为eglCreateWindowSurface中指定的EGLNativeWindowType的值,最后窗口坐标换算公式如下:

【OpenGL ES】图元装配与光栅化_第4张图片

使用glDepthRangef指定从规范化设备坐标到窗口坐标变换的深度范围,n表示近平面,初始值为0,f表示远平面,初始值为1,变化范围从0到1,窗口坐标Zw等于Zd * (f - n) / 2 + (f + n) / 2。

void glViewport(GLint x,
    GLint y,
    GLsizei width,
    GLsizei height);
void glDepthRangef(GLfloat n,
    GLfloat f);

7、光栅化

在顶点变换和图元裁剪之后,光栅化管线取得单独图元,并为该图元生成对应的片段,每个片段由屏幕空间中的位置(x,y)标识,片段代表了屏幕空间中(x,y)指定的像素位置和由片段着色器处理而生成片段颜色的附加片段数据。

以三角形为例,在三角形被光栅化之前,需要进行剔除操作Culling,剔除操作抛弃背向观察者的三角形,以避免GPU去光栅化不可见的三角形,提升应用程序的整体性能。剔除涉及两个概念,方向与正反面,从三角形的第一个顶点开始,到第二个、第三个顶点,再返回第一个顶点,其弯曲方向为顺时针或逆时针方向,然后面向观察者的三角形面为正面,另一面为反面。默认情况下,剔除被禁用,启用时用GL_CULL_FACE调用glEnable,禁用则调用glDisable,然后使用glFrontFace指定三角形正面的方向,默认为GL_CCW即逆时针方向,有效值还可以是GL_CW即顺时针方向,最后使用glCullFace指定剔除哪些面,默认为GL_BACK背面,有效值还包括GL_FORNT、GL_FORNT_AND_BACK。

void glFrontFace(GLenum mode);
void glCullFace(GLenum mode);

当绘制两个相互重叠的多边形时,很有可能产生深度缓冲伪像,这是因为三角形光栅化时精度有限,从而影响逐片段处理生成的深度值的精度而产生伪像。为了避免伪像,需要在执行深度测试和深度值写入深度缓冲区之前,把计算出来的深度值加一个偏移量,如果深度测试通过,原始深度值将被保存到深度缓冲区。启用或禁用深度偏移使用GL_POLYGON_OFFSET_FILL调用glEnable或glDisable。深度偏移调用如下的glPolygonOffset,factor和units的初始值都为0,这两个值可以通过glGet函数查询GL_POLYGON_OFFSET_FACTOR、GL_POLYGON_OFFSET_UNITS,深度偏移等于m * factor + r * units,m是三角形的最大深度斜率,在三角形光栅化阶段由OpenGL ES实现计算,r是OpenGL ES实现定义的常量,代表深度值中可以保证产生差异的最小值。

void glPolygonOffset(GLfloat factor,
    GLfloat units);

这里写图片描述

最后,介绍一个深度缓冲相关函数glDepthFunc,参数func可以是GL_NEVER、GL_LESS、GL_EQUAL、GL_LEQUAL、GL_GREATER、GL_NOTEQUAL、GL_GEQUAL、GL_ALWAYS,默认值为GL_LESS,默认情况下,将来的深度值小于存储的深度值时将通过深度测试。

void glDepthFunc(GLenum func);

8、遮挡查询

遮挡查询,就是用查询对象来跟踪通过深度测试的任何片段或样本,这种方法可用于不同的技术,例如镜头眩光特效的可见性测试,以及避免在可视体被遮挡的不可见对象上进行几何形状处理的优化,涉及如下几个函数,为了获得更好的性能,应该等待几帧再执行glGetQueryObjectuiv调用,以等待GPU中的结果可用。

GLboolean glIsQuery(GLuint id);
void glGenQueries(GLsizei n,
    GLuint * ids);
void glDeleteQueries(GLsizei n,
    const GLuint * ids);
void glBeginQuery(GLenum target,
    GLuint id);
void glEndQuery(GLenum target);
void glGetQueryiv(GLenum target,
    GLenum pname,
    GLint * params);
void glGetQueryObjectuiv(GLuint id,
    GLenum pname,
    GLuint * params);

下图是用一次实例化绘图调用绘制100个立方体的结果,其中每个立方体实例的颜色不同。

【OpenGL ES】图元装配与光栅化_第5张图片

// Instancing.c
#define NUM_INSTANCES   100
#define POSITION_LOC    0
#define COLOR_LOC       1
#define MVP_LOC         2

typedef struct
{
   // Handle to a program object
   GLuint programObject;
   // VBOs
   GLuint positionVBO;
   GLuint colorVBO;
   GLuint mvpVBO;
   GLuint indicesIBO;
   // Number of indices
   int       numIndices;
   // Rotation angle
   GLfloat   angle[NUM_INSTANCES];
} UserData;

// Initialize the shader and program object
int Init ( ESContext *esContext )
{
   GLfloat *positions;
   GLuint *indices;

   UserData *userData = esContext->userData;
   const char vShaderStr[] =
      "#version 300 es                             \n"
      "layout(location = 0) in vec4 a_position;    \n"
      "layout(location = 1) in vec4 a_color;       \n"
      "layout(location = 2) in mat4 a_mvpMatrix;   \n"
      "out vec4 v_color;                           \n"
      "void main()                                 \n"
      "{                                           \n"
      "   v_color = a_color;                       \n"
      "   gl_Position = a_mvpMatrix * a_position;  \n"
      "}                                           \n";

   const char fShaderStr[] =
      "#version 300 es                                \n"
      "precision mediump float;                       \n"
      "in vec4 v_color;                               \n"
      "layout(location = 0) out vec4 outColor;        \n"
      "void main()                                    \n"
      "{                                              \n"
      "  outColor = v_color;                          \n"
      "}                                              \n";

   // Load the shaders and get a linked program object
   userData->programObject = esLoadProgram ( vShaderStr, fShaderStr );

   // Generate the vertex data
   userData->numIndices = esGenCube ( 0.1f, &positions,
                                      NULL, NULL, &indices );

   // Index buffer object
   glGenBuffers ( 1, &userData->indicesIBO );
   glBindBuffer ( GL_ELEMENT_ARRAY_BUFFER, userData->indicesIBO );
   glBufferData ( GL_ELEMENT_ARRAY_BUFFER, sizeof ( GLuint ) * userData->numIndices, indices, GL_STATIC_DRAW );
   glBindBuffer ( GL_ELEMENT_ARRAY_BUFFER, 0 );
   free ( indices );

   // Position VBO for cube model
   glGenBuffers ( 1, &userData->positionVBO );
   glBindBuffer ( GL_ARRAY_BUFFER, userData->positionVBO );
   glBufferData ( GL_ARRAY_BUFFER, 24 * sizeof ( GLfloat ) * 3, positions, GL_STATIC_DRAW );
   free ( positions );

   // Random color for each instance
   {
      GLubyte colors[NUM_INSTANCES][4];
      int instance;

      srandom ( 0 );

      for ( instance = 0; instance < NUM_INSTANCES; instance++ )
      {
         colors[instance][0] = random() % 255;
         colors[instance][1] = random() % 255;
         colors[instance][2] = random() % 255;
         colors[instance][3] = 0;
      }

      glGenBuffers ( 1, &userData->colorVBO );
      glBindBuffer ( GL_ARRAY_BUFFER, userData->colorVBO );
      glBufferData ( GL_ARRAY_BUFFER, NUM_INSTANCES * 4, colors, GL_STATIC_DRAW );
   }

   // Allocate storage to store MVP per instance
   {
      int instance;

      // Random angle for each instance, compute the MVP later
      for ( instance = 0; instance < NUM_INSTANCES; instance++ )
      {
         userData->angle[instance] = ( float ) ( random() % 32768 ) / 32767.0f * 360.0f;
      }

      glGenBuffers ( 1, &userData->mvpVBO );
      glBindBuffer ( GL_ARRAY_BUFFER, userData->mvpVBO );
      glBufferData ( GL_ARRAY_BUFFER, NUM_INSTANCES * sizeof ( ESMatrix ), NULL, GL_DYNAMIC_DRAW );
   }
   glBindBuffer ( GL_ARRAY_BUFFER, 0 );

   glClearColor ( 1.0f, 1.0f, 1.0f, 0.0f );
   return GL_TRUE;
}

// Update MVP matrix based on time
void Update ( ESContext *esContext, float deltaTime )
{
   UserData *userData = ( UserData * ) esContext->userData;
   ESMatrix *matrixBuf;
   ESMatrix perspective;
   float    aspect;
   int      instance = 0;
   int      numRows;
   int      numColumns;

   // Compute the window aspect ratio
   aspect = ( GLfloat ) esContext->width / ( GLfloat ) esContext->height;

   // Generate a perspective matrix with a 60 degree FOV
   esMatrixLoadIdentity ( &perspective );
   esPerspective ( &perspective, 60.0f, aspect, 1.0f, 20.0f );

   glBindBuffer ( GL_ARRAY_BUFFER, userData->mvpVBO );
   matrixBuf = ( ESMatrix * ) glMapBufferRange ( GL_ARRAY_BUFFER, 0, sizeof ( ESMatrix ) * NUM_INSTANCES, GL_MAP_WRITE_BIT );

   // Compute a per-instance MVP that translates and rotates each instance differnetly
   numRows = ( int ) sqrtf ( NUM_INSTANCES );
   numColumns = numRows;

   for ( instance = 0; instance < NUM_INSTANCES; instance++ )
   {
      ESMatrix modelview;
      float translateX = ( ( float ) ( instance % numRows ) / ( float ) numRows ) * 2.0f - 1.0f;
      float translateY = ( ( float ) ( instance / numColumns ) / ( float ) numColumns ) * 2.0f - 1.0f;

      // Generate a model view matrix to rotate/translate the cube
      esMatrixLoadIdentity ( &modelview );

      // Per-instance translation
      esTranslate ( &modelview, translateX, translateY, -2.0f );

      // Compute a rotation angle based on time to rotate the cube
      userData->angle[instance] += ( deltaTime * 40.0f );

      if ( userData->angle[instance] >= 360.0f )
      {
         userData->angle[instance] -= 360.0f;
      }

      // Rotate the cube
      esRotate ( &modelview, userData->angle[instance], 1.0, 0.0, 1.0 );

      // Compute the final MVP by multiplying the
      // modevleiw and perspective matrices together
      esMatrixMultiply ( &matrixBuf[instance], &modelview, &perspective );
   }

   glUnmapBuffer ( GL_ARRAY_BUFFER );
}

// Draw a triangle using the shader pair created in Init()
void Draw ( ESContext *esContext )
{
   UserData *userData = esContext->userData;

   // Set the viewport
   glViewport ( 0, 0, esContext->width, esContext->height );

   // Clear the color buffer
   glClear ( GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT );

   // Use the program object
   glUseProgram ( userData->programObject );

   // Load the vertex position
   glBindBuffer ( GL_ARRAY_BUFFER, userData->positionVBO );
   glVertexAttribPointer ( POSITION_LOC, 3, GL_FLOAT,
                           GL_FALSE, 3 * sizeof ( GLfloat ), ( const void * ) NULL );
   glEnableVertexAttribArray ( POSITION_LOC );

   // Load the instance color buffer
   glBindBuffer ( GL_ARRAY_BUFFER, userData->colorVBO );
   glVertexAttribPointer ( COLOR_LOC, 4, GL_UNSIGNED_BYTE,
                           GL_TRUE, 4 * sizeof ( GLubyte ), ( const void * ) NULL );
   glEnableVertexAttribArray ( COLOR_LOC );
   glVertexAttribDivisor ( COLOR_LOC, 1 ); // One color per instance

   // Load the instance MVP buffer
   glBindBuffer ( GL_ARRAY_BUFFER, userData->mvpVBO );

   // Load each matrix row of the MVP.  Each row gets an increasing attribute location.
   glVertexAttribPointer ( MVP_LOC + 0, 4, GL_FLOAT, GL_FALSE, sizeof ( ESMatrix ), ( const void * ) NULL );
   glVertexAttribPointer ( MVP_LOC + 1, 4, GL_FLOAT, GL_FALSE, sizeof ( ESMatrix ), ( const void * ) ( sizeof ( GLfloat ) * 4 ) );
   glVertexAttribPointer ( MVP_LOC + 2, 4, GL_FLOAT, GL_FALSE, sizeof ( ESMatrix ), ( const void * ) ( sizeof ( GLfloat ) * 8 ) );
   glVertexAttribPointer ( MVP_LOC + 3, 4, GL_FLOAT, GL_FALSE, sizeof ( ESMatrix ), ( const void * ) ( sizeof ( GLfloat ) * 12 ) );
   glEnableVertexAttribArray ( MVP_LOC + 0 );
   glEnableVertexAttribArray ( MVP_LOC + 1 );
   glEnableVertexAttribArray ( MVP_LOC + 2 );
   glEnableVertexAttribArray ( MVP_LOC + 3 );

   // One MVP per instance
   glVertexAttribDivisor ( MVP_LOC + 0, 1 );
   glVertexAttribDivisor ( MVP_LOC + 1, 1 );
   glVertexAttribDivisor ( MVP_LOC + 2, 1 );
   glVertexAttribDivisor ( MVP_LOC + 3, 1 );

   // Bind the index buffer
   glBindBuffer ( GL_ELEMENT_ARRAY_BUFFER, userData->indicesIBO );

   // Draw the cubes
   glDrawElementsInstanced ( GL_TRIANGLES, userData->numIndices, GL_UNSIGNED_INT, ( const void * ) NULL, NUM_INSTANCES );
}

// Cleanup
void Shutdown ( ESContext *esContext )
{
   UserData *userData = esContext->userData;

   glDeleteBuffers ( 1, &userData->positionVBO );
   glDeleteBuffers ( 1, &userData->colorVBO );
   glDeleteBuffers ( 1, &userData->mvpVBO );
   glDeleteBuffers ( 1, &userData->indicesIBO );

   // Delete program object
   glDeleteProgram ( userData->programObject );
}

int esMain ( ESContext *esContext )
{
   esContext->userData = malloc ( sizeof ( UserData ) );

   esCreateWindow ( esContext, "Instancing", 640, 480, ES_WINDOW_RGB | ES_WINDOW_DEPTH );

   if ( !Init ( esContext ) )
   {
      return GL_FALSE;
   }

   esRegisterShutdownFunc ( esContext, Shutdown );
   esRegisterUpdateFunc ( esContext, Update );
   esRegisterDrawFunc ( esContext, Draw );

   return GL_TRUE;
}

你可能感兴趣的:(图形图像)