openGL ES 教程(三):画一个三角形

1. iOS 中初始化上下文

iOS 中不需要开发者调用 openGL ES 相关 Api 来设置上下文,貌似也没有找到类似 glfw 的三方框架来设置 window,感觉也没必要,所以 window 和上下文的概念就不再赘述了。

iOS 中直接使用 GLKViewController 中的 GLKView 就可以初始化上下文:

- (void)setupConfig {
    //新建OpenGLES 上下文
    self.mContext = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
    //VC是GLViewContrlller,storyboard要修改类型
    GLKView* view = (GLKView *)self.view;
    view.context = self.mContext;
    view.drawableColorFormat = GLKViewDrawableColorFormatRGBA8888;  //颜色缓冲区格式
    [EAGLContext setCurrentContext:self.mContext];
}

2. 顶点数据准备

这里使用三个顶点来画一个三角形:

// create Vertex Array
GLfloat vertex[] = {
    0.0,0.5,0.0, // point1:x,y,z
    0.5,-0.5,0.0,// point2:x,y,z
    -0.5,-0.5,0.0// point3:x,y,z
};

这里需要知道一个概念:标准化设备坐标(Normalized Device Coordinates, NDC);

标准坐标轴忽略 z 轴之后如下:

NDC

顶点着色器的一大任务就是需要将输入的顶点处理成标准坐标,因为这只是一个例子,我们在后面的顶点着色器中会直接使用输入的坐标,所以这里生成的三个顶点是以标准坐标轴来构建的;

3. 传递顶点数据到 GPU

上文中的三个顶点是在 CPU 内存中创建,而渲染管线是在 GPU 中完成的,所以需要将这些顶点着色器传入 GPU 中供后续阶段的着色器使用。

GPU 中的内存通常使用 Buffer 来表示,Buffer 有种,常见的 Frame Buffer 就是其中的一种。

现在需要将顶点数组传递给 GPU 中的 Buffer,就需要创建一个 Buffer 来接收这些数据,这就是 VBO(Vertex Buffer Object)。VBO 可以一次性发送一大批数据到显卡上,当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,其创建过程如下:

// 创建VBO(Vertex Buffer Object),用于从CPU发送顶点数据到GPU
unsigned int VBO;
// 第一个参数是数量?第二个参数是地址,当id来使用
glGenBuffers(1, &VBO);

上文就是一个 Buffer 的创建,创建完成之后还需要进行绑定类型,以此来告诉 GPU 这个 Buffer 是用来做什么的,顶点数据 Buffer 的类型是 GL_ARRAY_BUFFER,绑定代码如下:

// 绑定缓冲类型,顶点缓冲类型是GL_ARRAY_BUFFER
glBindBuffer(GL_ARRAY_BUFFER, VBO);

绑定完成之后,接下来在 GL_ARRAY_BUFFER 类型的缓冲函数的调用就都会操作这个 VBO,可以使用 glBufferData 来从 CPU 传递数据到 GPU :

// 复制顶点数据到缓冲内存(CPU->GPU)
glBufferData(GL_ARRAY_BUFFER, sizeof(triangleVertices), triangleVertices, GL_STATIC_DRAW);

glBufferData是一个专门用来把用户定义的数据复制到当前绑定缓冲的函数,其入参如下:

  • 第一个参数:目标缓冲的类型;

当顶点缓冲对象当前绑定到GL_ARRAY_BUFFER目标上;

  • 第二个参数:指定传输数据的大小(以字节为单位);

用一个简单的sizeof 计算出顶点数据大小就行。这个 demo 中顶点数组直接定义在同一函数内,如果数组作为指针传递过来的,那么还需要同时传递 length,因为 sizeof 计算出来的永远是指针的大小,而不是数组的真实大小;

  • 第三个参数:实际数据;

这里直接传入顶点数组即可;

  • 第四个参数:指定了我们希望显卡如何管理给定的数据;

这个参数决定数据被写入内存中哪个部分,如高速缓存还是普通内存。它有三种形式:
GL_STATIC_DRAW :数据不会或几乎不会改变。
GL_DYNAMIC_DRAW:数据会被改变很多。
GL_STREAM_DRAW :数据每次绘制时都会改变。
三角形的位置数据不会改变,每次渲染调用时都保持原样,所以它的使用类型最好是 GL_STATIC_DRAW。如果,比如说一个缓冲中的数据将频繁被改变,那么使用的类型就是 GL_DYNAMIC_DRAW 或GL_STREAM_DRAW,这样就能确保显卡把数据放在能够高速写入的内存部分。

上述函数调用完毕之后,顶点数据已经从 CPU 传递到了 GPU,接下来可以进行渲染操作了;

虽然有些手机设备上 GPU 和 CPU 共享内存,即 GPU 的 Buffer 也是位于 CPU 内存上的,但是学习时应当做区分,这样对概念会比较清晰;

4. 顶点着色器

要使用着色器,就需要知道 GLSL(OpenGL Shading Language),openGL ES 中的着色器语言则称为 GLSL ES;

着色器本身是一个微型程序,编写该程序需要使用对应的语言,iOS 中使用的是 OpenGL ES 3.0,对应的 GLSL ES 3.0。GLSL 其实和 C 语言很类似,语法也很简单,只需要注意一些特定的规则即可,比如 version 的声明、in 和 out 来制定输入和输出参数等,这里就不再赘述了,具体语法可以参考官方文档:

https://www.khronos.org/registry/OpenGL/specs/es/3.0/GLSL_ES_Specification_3.00.pdf

顶点被传入 GPU 之后首先需要被顶点着色器处理成标准坐标轴,这里我们简单起见,直接使用将输入的顶点进行输出,所以,这个简单的着色器代码如下:

// GLSL ES  3.0 版本
#version 300 es
// 输入的顶点是一个分量为3(vec3)的向量aPos(变量名)
layout (location = 0) in vec3 aPos;

void main() {
  // 这里直接传递输入的顶点数据作为输出
  gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
  // 第二种写法:
  gl_Position = vec4(aPos,1.0);
}
  1. 版本号

上述代码中,第一行首先声明了 GLSL 的版本为 ES 版本,且版本号为 300;

GLSL ES 中,需要在第一行使用 #version number es 来声明版本,且必须在所有预处理命令和注释之前;

如果不使用 #version 来声明版本,则默认使用 1.0 版本的 GLSL ES;

文档如下:

version
  1. 向量

向量是 GLSL 中的一种变量类型,文档如下:

向量

解释一下:

  1. vec + number :表示该向量有多少个分量,比如上文中的 vec3 表示有三个分量,这里用来表示顶点的 x,y,z,而输出的 vec4 表示有 4 个分量。这里 vec4 中第四个分量为w,不表示空间位置 ,而是和透视除法有关,暂时都设置为1;

  2. type + vec :表示向量中分量的类型,默认是 float,所以省略了。但是如果是其他类型则需要加上 type 的前缀,比如 ivec2 表示有 2 个分量类型为 int 的向量;

  3. in + location

in 的官方文档如下:

in + location

因为顶点着色器是第一个着色器,直接接受外部的数据。而其他着色器只能从上一个着色器接收输入参数。所以,顶点着色器可以使用 location 来表示需要从顶点数组中的哪个位置开始取数据。这个示例中显然第一个顶点就是有效数据,所以设置 location = 0;

in 表示输入的顶点属性。顶点属性是指顶点数据会被怎样的方式解析,类似于 MVC 中的 Data Model 的角色,后文会讲到。这里,使用 in 来表示输入的顶点属性是一个 vec3 的向量;

至于 layout 的其他作用,可以自行查阅官方文档;

  1. out

官方文档如下:

out

同顶点着色器类似,片段着色器作为混合前的最后一个输出,也可以定义 location。

另外,out 表述输出到下一个着色器的数据类型,暂不赘述;

  1. gl_Position

gl_Position 表示顶点着色器输出结果,顶点着色器需要输出一个分量为4的向量,所以这里没有声明 out ;

至于代码中的两种写法皆可,具体可以参照 GLSL ES 的语法标准;

5. 编译顶点着色器

顶点着色器的代码写完之后还需要编译,而编译时只接受 C 类型的 char 字符,所以需要这样转换:

const char *vertexShaderSource = "#version 300 es\n"
    "layout (location = 0) in vec3 aPos;\n"
    "void main() {\n"
    "   gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);\n"
    "}\0";

接下来需要编译着色器,编译着色器之前需要创建着色器:

// 创建着色器
GLuint vertexShader;
vertexShader = glCreateShader(GL_VERTEX_SHADER);

创建完成之后就可以编译了:

/**
 * glShaderSource函数把要编译的着色器对象作为第一个参数。
 * 第二参数指定了传递的源码字符串数量,这里只有一个。
 * 第三个参数是顶点着色器真正的源码
 * 第四个参数我们先设置为NULL。
 */
glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
// 编译着色器
glCompileShader(vertexShader);

编译着色器时,可能会因为语法或者版本等原因而报错,可以通过下面的方法来获取报错信息:

// 检测编译是否成功
int  success;
char infoLog[512];
glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
if (!success) {
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    NSLog(@"shader compile failed:%s",infoLog);
}

6. 片段着色器

因为系统有提供几何着色器,所以为了简单起见,我们使用系统默认的几何着色器即可,直接来书写片段着色器;

本例子中的着色器相当简单,直接输出蓝色即可:

#version 300 es
layout (location = 0) out lowp vec4 myColor;
void main() {
    myColor = vec4(1.0, 0.5, 0.2, 1.0);
}

这里有几个注意点:

  1. layout + location 上文已经讲了个大概,不再赘述;
  2. GLSL 3.0 中,对于片段片段着色器的输出,需要指示精度;

如果不指示精度会报错:

2022-04-20 11:08:45.211535+0800 XKOpenGL[11112:1652554] ERROR: 0:2: 'vec4' : declaration must include a precision qualifier for type

2D 中精度使用 lowp 即可:

精度选择

7. 编译片段着色器

编译过程和顶点着色器一致,不再赘述:

const char *fragmentShaderSource = "#version 300 es\n"
    "layout (location = 0) out lowp vec4 myColor;\n"
    "void main() {\n"
        "myColor = vec4(1.0, 0.5, 0.2, 1.0);\n"
    "}\0";
unsigned int fragmentShader;
fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
glCompileShader(fragmentShader);

int  fragmentCompileSuccess;
char fragmentInfoLog[512];
glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &fragmentCompileSuccess);
if(!fragmentCompileSuccess){
    glGetShaderInfoLog(fragmentShader, 512, NULL, fragmentInfoLog);
    NSLog(@"%s",fragmentInfoLog);
}

8. 链接着色器生成着色器程序

编译之后的着色器程序还需要连接到主着色器程序中,其代码如下:

// 创建着色器程序
unsigned int shaderProgram;
shaderProgram = glCreateProgram();
// 添加着色器
glAttachShader(shaderProgram, vertexShader);
glAttachShader(shaderProgram, fragmentShader);
// 链接
glLinkProgram(shaderProgram);

int linkSuccess;
char linkInfoLog[512];
glGetProgramiv(shaderProgram, GL_LINK_STATUS, &linkSuccess);
if(!linkSuccess) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, linkInfoLog);
    NSLog(@"%s",linkInfoLog);
}

9. 激活着色器程序并清理已链接完成的着色器

激活着色器主程序之后,pipline 中就会使用该着色器程序来执行整个 pipline;

另外,链接完成的着色器代码已经被复制到了着色器主程序,应当删除以释放内存;

代码如下:

// 激活程序,每个着色器调用和渲染调用都会使用这个程序对象
glUseProgram(shaderProgram);
// 链接到着色器程序之后(相当于被打包生成了可执行程序),就可以删除原来的两个小着色器了
glDeleteShader(vertexShader);
glDeleteShader(fragmentShader);

10. 顶点属性解析

通过上面的步骤,我们已经完成了:
1.已经把顶点数据从CPU内存复制到了GPU缓存
2.使用顶点着色器指示了 GPU 如何处理顶点并输出给下一个着色器
3.使用片段着色器指示了 GPU 生成的像素的色值
4.编译并链接了两个着色器生成了最终的着色器程序
5.激活了着色器程序,后续pipline都使用这个着色器程序
6.删除了已经链接完成的两个着色器

至此,是不是可以调用 draw call 了?并不能,因为 OpenGL 还不知道它该如何解释 Buffer 中的顶点数据。

顶点数据传递到 GPU 后仍然是一对浮点类型的数组,一个顶点该取 4 个元素还是 3 个?从哪个位置开始取值?第二个点又从哪里取?等等一系列问题 GPU 都是不知道的,所以还需要告诉顶点着色器如何解析这些顶点数据:

// 以点的方式来解析顶点
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,  3 * sizeof(float), (void*)0);
// 顶点属性默认是禁用的,这里启动顶点数据
glEnableVertexAttribArray(0);
  • 第一个参数:指定我们要配置的顶点属性

这个参数和 layout (location = 0) 中的 location 类似,告诉着色器应该从顶点数组的第几个元素开始取数据;

  • 第二个参数:指定顶点属性的大小

这里的大小是指数组中元素的个数。顶点属性是一个 vec3,它由 3 个 float 类型的值来标识一个顶点,所以大小是3。

  • 第三个参数:指定数据的类型

这里是 GL_FLOAT;

  • 第四个参数:是否希望数据被标准化(Normalize)。

如果我们设置为 GL_TRUE,所有数据都会被映射到 0(对于有符号型signed数据是-1)到1之间。我们把它设置为 GL_FALSE。

  • 第五个参数:步长(Stride)

补偿告诉我们在连续的顶点属性组之间的间隔。由于下个组位置数据在3个 float 之后,我们把步长设置为 3 * sizeof(float)。

要注意的是由于我们知道这个数组是紧密排列的(在两个顶点属性之间没有空隙)我们也可以设置为 0 来让OpenGL决定具体步长是多少(只有当数值是紧密排列时才可用)。一旦我们有更多的顶点属性,我们就必须更小心地定义每个顶点属性之间的间隔,我们在后面会看到更多的例子;

  • 第六个参数:缓冲中起始位置的偏移量

该参数类型是void*,所以需要我们进行这个奇怪的强制类型转换。它表示位置数据在缓冲中起始位置的偏移量(Offset)。由于位置数据在数组的开头,所以这里是 0。

11. VAO

上面已经将顶点数据传递到了 GPU,且已经告诉了着色器如何取解析顶点数据,那是不是万事大吉了?

此时需要考虑一个场景:重复绘制。假设上图的顶点表示的三角形需要重复多次绘制,那么是不是上面的步骤中:

  1. Vertex Array 的生成;
  2. Buffer 的创建;
  3. 从 CPU 传递数据到 GPU 的 Buffer;
  4. 顶点数据属性设置;
    这些步骤都需要重新再做一次。

其实这种场景并不少见,比如 GLKViewController 中一秒会调用 60 次代理方法 - glkView:drawInRect: 进行绘制,如果上述的代码写在代理方法中,上述 4 个步骤每秒都要重复 60 次?这个是个人猜测,实际情况可以使用 Instrument 进行验证;

所以,此时 VAO 就出场了。

VAO:Vertex Array Object,顶点数组对象。用于记录顶点数组的数据和顶点数据属性的解析格式;

猜测 VAO 应该是指向 GPU 中的内存的?这样避免了频繁地从 CPU 向 GPU 传递数据;

另外,需要说明两点:

  1. OpenGL 的核心模式(core)要求我们使用 VAO,如果不绑定 VAO 或者绑定失败,那么 OpenGL 就不会进行任何绘制;
  2. draw call 调用时,以当前绑定的 VAO 所存储的数据来进行绘制;

总之,VAO 是你绕不过去的,所以还是好好学习下这是个啥吧~~~

官方解释有点虚幻,说人话就是这个方法会影响下列函数:

  1. glBufferData :将数据从 CPU 复制到 GPU
  2. glVertexAttribPointer :属性设置相关方法
  3. glEnableVertexAttribArray/glDisableVertexAttribArray:开启/关闭顶点数组

以上方法调用的结果会存储在 VAO 中,下次如果需要使用这些顶点和对应的属性解析格式,不需要进行上述四步,只需要重新 bind 这个 VAO 即可。

官方图示如下:

VAO和VBO

所以,VAO 相关的代码必须写在 VBO 之前,前面步骤的代码更新后如下:

// 创建 VAO
unsigned int VAO;
glGenVertexArrays(1, &VAO);
// 绑定 VAO
glBindVertexArray(VAO);

// 初始化顶点数组
GLfloat triangleVertices[] = {
    -0.5f, -0.5f, 0.0f,
     0.5f, -0.5f, 0.0f,
     0.0f,  0.5f, 0.0f
};

// 创建VBO
unsigned int VBO;
glGenBuffers(1, &VBO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);

// 从CPU传递数据到GPU
glBufferData(GL_ARRAY_BUFFER, sizeof(triangleVertices), triangleVertices, GL_STATIC_DRAW);

// 设置顶点属性
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE,  3 * sizeof(float), (void*)0);
// 启动顶点数据
glEnableVertexAttribArray(0);

... 省略着色器等代码...

上述代码调用了 glBindVertexArray 之后,该 VAO 就作为当前着色器的数据源了。后续继续调用了 glBufferDataglVertexAttribPointerglEnableVertexAttribArray 三个函数,相关的数据被绑定到了这个 VAO 上,所以可以直接进行 draw call 调用了~~~

12. draw call

iOS 中需要在 GLKView 的代理方法中进行 draw call 的调用:

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
    glClearColor(0.3f, 0.6f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    glDrawArrays(GL_TRIANGLES, 0, 3);
}

13. surprise

结果如下:

结果

14. 多个 VBO 之间的切换

官网代代码演示的只有一个 VAO,这里使用两个 VAO 来进一步认识 VAO 的角色和作用;

用代码来表示展示,首先创建两个 VAO:

@property(assign, nonatomic) GLuint vaoTriangleOne;
@property(assign, nonatomic) GLuint vaoTriangleTwo;

这里我们首先对上述 4 个可能重复的步骤以及 VAO 的绑定操作进行了封装,源码如下:

- (void)setupVertexArrayObject:(GLuint *)vao vertices:(GLfloat[])vertices length:(GLuint)length strideCount:(GLuint)strideCount {
    // create Vertex Array Object
    glGenVertexArrays(1, vao);
    glBindVertexArray(*vao);
    // create vertex buffer object
    GLuint vbo;
    glGenBuffers(1, &vbo);
    // bind buffer object
    glBindBuffer(GL_ARRAY_BUFFER, vbo);
    // copy data to buffer
    glBufferData(GL_ARRAY_BUFFER, length *sizeof(GLfloat), vertices, GL_STATIC_DRAW);
    // attr
    glVertexAttribPointer(0, strideCount, GL_FLOAT, GL_FALSE,  strideCount * sizeof(float), (void*)0);
    // 顶点属性默认是禁用的,这里启动顶点数据
    glEnableVertexAttribArray(0);
}

此时,就可以创建两个顶点数据来绑定 VAO 了:

GLfloat triangleOne[] = {
    0,0.5,1.0,
    0.5,-0.5,1.0,
    0,-0.5,1.0
};
[self setupVertexArrayObject:&_vaoTriangleOne vertices:triangleOne length:9 strideCount:3];

GLfloat triangleTwo[] = {
    0,0.5,1.0,
    -0.5,-0.5,1.0,
    0,-0.5,1.0
};
[self setupVertexArrayObject:&_vaoTriangleTwo vertices:triangleTwo length:9 strideCount:3];

上述代码调用了两次 glBindVertexArray 函数,也就是顶点数据在 GPU 中的位置、如何解析顶点属性、顶点数组是否开启,这个结果已经被保存在了两个 VAO 中了,接下来的调用代码可以简化成下面:

- (void)glkView:(GLKView *)view drawInRect:(CGRect)rect {
    glClearColor(0.3f, 0.6f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    if (self.shouldShowSecond) {
        glBindVertexArray(self.vaoTriangleTwo);
    } else {
        glBindVertexArray(self.vaoTriangleOne);
    }

    // shader program
    if (self.program != 0) {
        glDrawArrays(GL_TRIANGLES, 0, 3);
    }
}

演示效果如下:

switchVAO.gif
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容