3. 实例化-画100个正方体

3. 实例化-画100个正方体

目录

概述

数据的传递流程

3. 实例化-画100个正方体

(1)准备顶点属性缓冲区

  • positionVBO:用于存放正方体顶点位置的缓冲区,一个正方体需要24个顶点位置来描述,一个顶点位置三个数(x,y,z)

  • colorVBO:用于存放正方体颜色的缓冲区,每个正方体一种颜色,这里一共有100个正方体,一个颜色四个数(x,y,z,a)

  • mvpVBO:用于存放正方体的mvp变换矩阵的缓冲区,每个正方体一个mvp变换矩阵,这里一共有100个正方体

  • indicesIBO:用于存放正方体顶点顺序的缓冲区,比如它为{0,2,4,6,8,10},那么就从positionVBO中取出下标为{0,2,4,6,8,10}的顶点位置,画两个三角形(一个三角形三个顶点);一个正方体需要12个三角形来描述,所以它为36个indices

(2)计算mvp矩阵

(3)将数据传给顶点着色器以及片段着色器来画图

图形学原理

1. 齐次坐标

齐次坐标(x,y,z,w),它是为了兼容点的平移操作,使得我们可以用同一个公式对点和方向作运算。

(x,y,z,w)同时除于w得到坐标(x/w,y/w,x/w)

  • 当w == 1时,向量(x,y,z,1)为空间中的点

  • 当w == 0时,向量(x,y,z,0)为方向

齐次坐标主要是兼容点的平移操作,在空间中平移方向是没有意义的:

3. 实例化-画100个正方体

2. 二维坐标间的转换

1. 二维旋转矩阵

把点(x,y)旋转到点(x',y')

3. 实例化-画100个正方体

2. 先平移后旋转,以及先旋转后平移问题

也就是矩阵乘法的顺序问题,设旋转矩阵为R,平移矩阵为T

  • 先平移后旋转:M = R * T

  • 先旋转后平移:M = T * R

3. 实例化-画100个正方体

3. 二维坐标转换

在xy坐标系中,有一点P(x0,y0),表示的是:点P(x0,y0)相对于xy坐标系原点的值为x0和y0。

转换到x'y'坐标系之后,变为P(x0',y0'),表示的是:点P(x0',y0')相对于x'y'坐标系原点的值为x0'和y0'

它们之间的相对位置时不变的,只是换了一种表示方法。

就比如:小明说,杯子在我的右边;小东说,杯子在我的左边;是一样的道理。这里就是把(杯子在我右边)转换为(杯子在我左边)

3. 实例化-画100个正方体

(1)为了将对象描述从xy坐标变换到x'y'坐标,必须建立把x'y'轴叠加到xy轴的变换,这需要分两步进行:

  1. 将x'y'系统的坐标原点(x0,y0)平移到xy系统的原点(0,0);

  2. 将x'轴旋转到x轴上

所以,就是先平移后旋转:M = R * T

举例:设x'轴与x轴之间的夹角为45度,x'y'系统的坐标原点为(2,2),将点P(1,1)变换到x'y'系统上,由几何关系可以得到变换后P点坐标的值为(-√2,0)

3. 实例化-画100个正方体

(2)任何旋转矩阵的元素可以表示为一组正交单位向量的元素

3. 实例化-画100个正方体

4. 旋转矩阵的逆矩阵

旋转矩阵的逆矩阵可以通过矩阵转置,或者将旋转角取负值来获得

3. 实例化-画100个正方体

3. 三维坐标间转换

1. 三维坐标绕轴旋转

3. 实例化-画100个正方体

2. 轴角与旋转矩阵

(1)轴角:绕一个给定轴K(x,y,z)(向量)旋转给定角度。也就是原定坐标轴{A}绕给定向量K(x,y,z)旋转给定角度后,得到坐标系{B}

注意:向量K(x,y,z)为单位向量

它的旋转矩阵为:

3. 实例化-画100个正方体

3. 实例化-画100个正方体

(2)也可以理解为:一个向量V绕着向量K旋转角,得到向量V(rot)
公式的推导:详情请见:https://www.bilibili.com/video/BV1h7411c7zK?from=search&seid=5987430286330119296

推导过程(TODO)

涉及到:欧拉角,四元数,旋转矩阵,轴角之间的关系

4. MVP矩阵

1. 不同的坐标系
  • 局部空间(Local Space,或者称为物体空间(Object Space)):物体坐标系

  • 世界空间(World Space):世界坐标系

  • 观察空间(View Space,或者称为视觉空间(Eye Space)):眼睛坐标系

  • 裁剪空间(Clip Space)

  • 屏幕空间(Screen Space)

3. 实例化-画100个正方体

由上图可以知道:

  • M:模型矩阵:将物体坐标变换为世界坐标

  • V:视图矩阵:将世界坐标变换为眼睛坐标

  • P:投影矩阵:将眼睛坐标变换为裁剪坐标

2. 模型矩阵

将物体坐标变换为世界坐标:

3. 实例化-画100个正方体

3. 视图矩阵

将世界坐标变换为眼睛坐标

3. 实例化-画100个正方体

4. 投影矩阵

将眼睛坐标变换为裁剪坐标

1. 正交投影

3. 实例化-画100个正方体

将一个上下坐标为t(top)和b(bottom),前后坐标为n(near)和f(far),左右坐标为l(left)和r(right)的正方体:

  1. 将其中心移动到坐标原点

  2. 压缩成边长为2(-1,1)的正方体

3. 实例化-画100个正方体

2. 齐次坐标不变性

(x,y,z,1)和(kx,ky,kz,k!=0z)和(xz,yz,z^2,z!=0)在三维空间中,这些都代表的是同一个点(x,y,z)

例如:(1,0,0,1)和(2,0,0,2)都代表着点(1,0,0)

3. 透视投影

将左边的梯形体压缩成右边的长方体

注意:n和f是不变的,所以,可以得出:

(1)对于任何在n平面上的点,其坐标的z分量不变

(2)对于任何在f平面上的点,其坐标的z分量不变

3. 实例化-画100个正方体

从下图可以看出,(x,y,z)坐标和(x',y',z')坐标之间存在相似三角形关系

3. 实例化-画100个正方体

3. 实例化-画100个正方体

4. 得出投影矩阵

投影矩阵就是先做透视投影,把梯形体压缩成一个长方体;然后做正交投影,把这个正方体,放到原点,并压缩成边长为2(-1,1)的正方体

3. 实例化-画100个正方体

OpenGL的为啥为负的?(TODO)

5. 如何求长方体上下左右前后的坐标

3. 实例化-画100个正方体

给出可视角度fovY,nearZ和长宽比aspect(16:9,或者4:3等等),就可以求出长方体的上下左右前后的坐标了

t(top),b(bottom)= - t,r(right),l(left)= -r,n(nearZ),f(farZ)

然后代入投影矩阵公式,就可以得出投影矩阵的值了。

源码解析

主程序

 #include "esUtil.h"
 #include <stdlib.h>
 #include <math.h>
 #include <android/log.h>
 
 #define NUM_INSTANCES 100
 #define POSITION_LOC 0
 #define COLOR_LOC 1
 #define MVP_LOC 2
 #define LOGI(...) ((void)__android_log_print(ANDROID_LOG_INFO, "lylesUtil", __VA_ARGS__))
 typedef struct
 {
  GLuint programObject;
 
  GLuint positionVBO;
  GLuint colorVBO;
  GLuint mvpVBO;
  GLuint indicesIBO;
  int numIndices;
  GLfloat angle[NUM_INSTANCES];
 } myUserData;
 
 // 初始化顶点着色器和片段着色器
 // 初始化myUserData里面的数据
 int Init(MYESContext *myesContext)
 {
  GLfloat *positions;
  GLuint *indices;
 
  myUserData *userData = (myUserData *)myesContext->userData;
  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"  // 在这里设置mvp变换矩阵
  "}                                          \n";
  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";
  userData->programObject = myesLoadProgram(vShaderStr, fShaderStr);
  // 1\. 生成正方体的position数据和indices数据
  userData->numIndices = myesGenCube(0.1f, &positions, NULL, NULL, &indices);
 
  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);
 
  glGenBuffers(1, &userData->positionVBO);
  glBindBuffer(GL_ARRAY_BUFFER, userData->positionVBO);
  glBufferData(GL_ARRAY_BUFFER, 24*sizeof(GLfloat)*3, positions, GL_STATIC_DRAW);
  free(positions);
 
  {
  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);
  }
 
  {
  int instance;
 
  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;
 }
 
 // 更新mvp变换矩阵
 void Update(MYESContext *myesContext, float deltaTime)
 {
  myUserData *userData = (myUserData*) myesContext->userData;
  ESMatrix *matrixBuf;
  ESMatrix perspective;
  float aspect;
  int instance = 0;
  int numRows;
  int numColumns;
 
  // 比例=长/高
  aspect = (GLfloat) myesContext->width / (GLfloat)myesContext->height;
  // 先得到一个单位矩阵
  myesMatrixLoadIdentity(&perspective);
  // 然后得到投影矩阵P
  myesPerspective(&perspective, 90.0f, aspect, 0.1f, 100.0f);
  glBindBuffer(GL_ARRAY_BUFFER, userData->mvpVBO);
  matrixBuf = (ESMatrix *)glMapBufferRange(GL_ARRAY_BUFFER, 0, sizeof(ESMatrix) * NUM_INSTANCES, GL_MAP_WRITE_BIT);
  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;
  // 先得到一个单位矩阵
  myesMatrixLoadIdentity(&modelview);
  // 然后将正方体平移到坐标(translateX, translateY, -1.0f),得到模型矩阵M
  myesTranslate(&modelview, translateX, translateY, -1.0f);
  userData->angle[instance] += (deltaTime*40.0f);
  if (userData->angle[instance] >= 360.0f) {
  userData->angle[instance] -= 360.0f;
  }
  // 绕轴(0,0,1)旋转angle角度,得到视图矩阵V,然后和前面的模型矩阵M相乘得到VM矩阵
  myesRotate(&modelview, userData->angle[instance], 0.0, 0, 1.0);
  // 再乘一下,得到VMP矩阵
  myesMatrixMultiply(&matrixBuf[instance], &modelview, &perspective);
  }
 
  glUnmapBuffer(GL_ARRAY_BUFFER);
 }
 
 void Draw(MYESContext *myesContext)
 {
  myUserData *userData = (myUserData *)myesContext->userData;
  // 视口变换
  glViewport(0, 0, myesContext->width, myesContext->height);
  glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
  glUseProgram(userData->programObject);
 
  glBindBuffer(GL_ARRAY_BUFFER, userData->positionVBO);
  glVertexAttribPointer(POSITION_LOC, 3, GL_FLOAT, GL_FALSE,
  3*sizeof(GLfloat), (const void *)NULL);
  glEnableVertexAttribArray(POSITION_LOC);
  glBindBuffer(GL_ARRAY_BUFFER, userData->colorVBO);
  glVertexAttribPointer(COLOR_LOC, 4, GL_UNSIGNED_BYTE,
  GL_TRUE, 4*sizeof(GLubyte), (const void *)NULL);
  glEnableVertexAttribArray(COLOR_LOC);
  // void glVertexAttribDivisor (GLuint index, GLuint divisor);
  // 指示OpenGL ES对每个实例(instance)读取一次或者多次顶点属性。
  // divisor为1,表示每个图元实例(每个正方体)读取一次顶点属性,相当于指针P+1这样子
  // divisor为0,则是(每个顶点),读取一次顶点属性
  glVertexAttribDivisor(COLOR_LOC, 1);
 
  glBindBuffer(GL_ARRAY_BUFFER, userData->mvpVBO);
  // 对于4x4矩阵,需要消耗4个顶点属性来存储它们
  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 + 1);
  glEnableVertexAttribArray(MVP_LOC + 1);
  glEnableVertexAttribArray(MVP_LOC + 2);
  glEnableVertexAttribArray(MVP_LOC + 3);
 
  glVertexAttribDivisor(MVP_LOC + 0, 1);
  glVertexAttribDivisor(MVP_LOC + 1, 1);
  glVertexAttribDivisor(MVP_LOC + 2, 1);
  glVertexAttribDivisor(MVP_LOC + 3, 1);
 
  glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, userData->indicesIBO);
  //void  glDrawElementsInstanced (GLenum mode, GLsizei count, GLenum type, const void *indices, GLsizei instancecount);
  // mode表示要渲染的图元;count为绘制的index数量;type为index中的元素索引类型;indices为存放index的地方;
  // instancecount为要绘制的图元实例的数量
  // 用一次API调用,进行多次渲染具有不同属性(例如不同的变换矩阵、颜色、或者大小)的一个对象
  // 这里就是用一次API调用,渲染了100个具有不同变换矩阵、颜色的正方体
  glDrawElementsInstanced(GL_TRIANGLES, userData->numIndices, GL_UNSIGNED_INT, (const void *)NULL, NUM_INSTANCES);
 }
 
 void Shutdown(MYESContext *myesContext)
 {
  myUserData *userData = (myUserData *)myesContext->userData;
 
  glDeleteBuffers(1, &userData->positionVBO);
  glDeleteBuffers(1, &userData->colorVBO);
  glDeleteBuffers(1, &userData->mvpVBO);
  glDeleteBuffers(1, &userData->indicesIBO);
  glDeleteProgram(userData->programObject);
 }
 
 
 int myesMain(MYESContext *myesContext)
 {
  myesContext->userData = malloc(sizeof(myUserData));
  // 这个width和height是没有用的,userData中的width和height是用的是系统中的
  myesCreateWindow(myesContext, "Example 7-1 Instancing", 640, 480, MY_ES_WINDOW_RGB | MY_ES_WINDOW_DEPTH);
 
  if (!Init(myesContext)) {
  return GL_FALSE;
  }
 
  esRegisterShutdownFunc(myesContext, Shutdown);
  esRegisterUpdateFunc(myesContext, Update);
  esRegisterDrawFunc(myesContext, Draw);
 
  return GL_TRUE;
 }

变换矩阵函数

// 生成单位矩阵
 void myesMatrixLoadIdentity(ESMatrix *result)
 {
  memset(result, 0x0, sizeof(ESMatrix));
  result->m[0][0] = 1.0f;
  result->m[1][1] = 1.0f;
  result->m[2][2] = 1.0f;
  result->m[3][3] = 1.0f;
 }
 
 // 生成投影矩阵的
 void myesPerspective(ESMatrix *result, float fovy, float aspect, float nearZ, float farZ)
 {
  GLfloat frustumW, frustumH;
 
  frustumH = tanf(fovy / 360.0f * PI) * nearZ;
  frustumW = frustumH * aspect;
 
  myesFrustum(result, -frustumW, frustumW, -frustumH, frustumH, nearZ, farZ);
 }
 
 void myesFrustum(ESMatrix *result, float left, float right, float bottom, float top, float nearZ, float farZ)
 {
  float deltaX = right - left;
  float deltaY = top - bottom;
  float deltaZ = farZ - nearZ;
  ESMatrix frust;
 
  if ((nearZ <= 0.0f) || (farZ <= 0.0f) ||
  (deltaX <= 0.0f) || (deltaY <= 0.0f) || (deltaZ <= 0.0f)) {
  return;
  }
 
  frust.m[0][0] = 2.0f * nearZ / deltaX;
  frust.m[0][1] = frust.m[0][2] = frust.m[0][3] = 0.0f;
 
  frust.m[1][1] = 2.0f * nearZ / deltaY;
  frust.m[1][0] = frust.m[1][2] = frust.m[1][3] = 0.0f;
 
  frust.m[2][0] = (right + left) / deltaX;
  frust.m[2][1] = (top + bottom) / deltaY;
  frust.m[2][2] = - (nearZ + farZ) / deltaZ;
  frust.m[2][3] = -1.0f;
 
  frust.m[3][2] = -2.0f * nearZ * farZ / deltaZ;
  frust.m[3][0] = frust.m[3][1] = frust.m[3][3] = 0.0f;
 
  myesMatrixMultiply(result, &frust, result);
 }
 
 // 矩阵乘法
 void myesMatrixMultiply ( ESMatrix *result, ESMatrix *srcA, ESMatrix *srcB )
 {
  ESMatrix    tmp;
  int         i;
 
  for ( i = 0; i < 4; i++ )
  {
  tmp.m[i][0] =  ( srcA->m[i][0] * srcB->m[0][0] ) +
  ( srcA->m[i][1] * srcB->m[1][0] ) +
  ( srcA->m[i][2] * srcB->m[2][0] ) +
  ( srcA->m[i][3] * srcB->m[3][0] ) ;
 
  tmp.m[i][1] =  ( srcA->m[i][0] * srcB->m[0][1] ) +
  ( srcA->m[i][1] * srcB->m[1][1] ) +
  ( srcA->m[i][2] * srcB->m[2][1] ) +
  ( srcA->m[i][3] * srcB->m[3][1] ) ;
 
  tmp.m[i][2] =  ( srcA->m[i][0] * srcB->m[0][2] ) +
  ( srcA->m[i][1] * srcB->m[1][2] ) +
  ( srcA->m[i][2] * srcB->m[2][2] ) +
  ( srcA->m[i][3] * srcB->m[3][2] ) ;
 
  tmp.m[i][3] =  ( srcA->m[i][0] * srcB->m[0][3] ) +
  ( srcA->m[i][1] * srcB->m[1][3] ) +
  ( srcA->m[i][2] * srcB->m[2][3] ) +
  ( srcA->m[i][3] * srcB->m[3][3] ) ;
  }
 
  memcpy ( result, &tmp, sizeof ( ESMatrix ) );
 }
 
 // 平移矩阵
 void myesTranslate ( ESMatrix *result, GLfloat tx, GLfloat ty, GLfloat tz )
 {
  result->m[3][0] += ( result->m[0][0] * tx + result->m[1][0] * ty + result->m[2][0] * tz );
  result->m[3][1] += ( result->m[0][1] * tx + result->m[1][1] * ty + result->m[2][1] * tz );
  result->m[3][2] += ( result->m[0][2] * tx + result->m[1][2] * ty + result->m[2][2] * tz );
  result->m[3][3] += ( result->m[0][3] * tx + result->m[1][3] * ty + result->m[2][3] * tz );
 }
 
 // 轴角,旋转矩阵
 void myesRotate ( ESMatrix *result, GLfloat angle, GLfloat x, GLfloat y, GLfloat z )
 {
  GLfloat sinAngle, cosAngle;
  GLfloat mag = sqrtf ( x * x + y * y + z * z );
 
  sinAngle = sinf ( angle * PI / 180.0f );
  cosAngle = cosf ( angle * PI / 180.0f );
 
  if ( mag > 0.0f )
  {
  GLfloat xx, yy, zz, xy, yz, zx, xs, ys, zs;
  GLfloat oneMinusCos;
  ESMatrix rotMat;
 
  x /= mag;
  y /= mag;
  z /= mag;
 
  xx = x * x;
  yy = y * y;
  zz = z * z;
  xy = x * y;
  yz = y * z;
  zx = z * x;
  xs = x * sinAngle;
  ys = y * sinAngle;
  zs = z * sinAngle;
  oneMinusCos = 1.0f - cosAngle;
 
  rotMat.m[0][0] = ( oneMinusCos * xx ) + cosAngle;
  rotMat.m[0][1] = ( oneMinusCos * xy ) - zs;
  rotMat.m[0][2] = ( oneMinusCos * zx ) + ys;
  rotMat.m[0][3] = 0.0F;
 
  rotMat.m[1][0] = ( oneMinusCos * xy ) + zs;
  rotMat.m[1][1] = ( oneMinusCos * yy ) + cosAngle;
  rotMat.m[1][2] = ( oneMinusCos * yz ) - xs;
  rotMat.m[1][3] = 0.0F;
 
  rotMat.m[2][0] = ( oneMinusCos * zx ) - ys;
  rotMat.m[2][1] = ( oneMinusCos * yz ) + xs;
  rotMat.m[2][2] = ( oneMinusCos * zz ) + cosAngle;
  rotMat.m[2][3] = 0.0F;
 
  rotMat.m[3][0] = 0.0F;
  rotMat.m[3][1] = 0.0F;
  rotMat.m[3][2] = 0.0F;
  rotMat.m[3][3] = 1.0F;
 
  myesMatrixMultiply ( result, &rotMat, result );
  }
 }

3. 实例化-画100个正方体

3. 实例化-画100个正方体

参考

1. 第三课:矩阵
http://www.opengl-tutorial.org/cn/beginners-tutorials/tutorial-3-matrices/
2. 旋转矩阵
https://zh.wikipedia.org/wiki/%E6%97%8B%E8%BD%AC%E7%9F%A9%E9%98%B5
3. CG07-3D变换和欧拉角/轴角/四元数
https://www.bilibili.com/video/BV1h7411c7zK?from=search&seid=5987430286330119296
4. GAMES101-现代计算机图形学入门-闫令琪
https://www.bilibili.com/video/BV1X7411F744?p=4
5. 坐标系统
https://learnopengl-cn.readthedocs.io/zh/latest/01%20Getting%20started/08%20Coordinate%20Systems/
6. View Transform(视图变换)详解
https://www.cnblogs.com/graphics/archive/2012/07/12/2476413.html
上一篇:8-加载立方图纹理


下一篇:4. 顶点着色器-mvp转换