视频特效学习03-OpenGL渲染技巧解析

学习目标:
  • 1.渲染过程产生的问题(掌握)
    1. 油画渲染(了解)
    1. 正面&背面剔除(掌握)
    1. 深度测试(掌握)
    1. ZFighting闪烁(了解)
    1. 窗口、视口、裁剪区域(掌握)
    1. 颜色混合(掌握)

1.渲染过程产生的问题

  • 核心代码
void setupRC(void){
    glClearColor(0.5, 0.5, 0.7, 1);
    shaderManager.InitializeStockShaders();
    viewFrame.MoveForward(10.0f);
    //创建“环”
    gltMakeTorus(torusBatch, .5f, .3f, 60, 30);
    
}
void renderScene(void){
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
    modelViewMatix.PushMatrix(viewFrame);
    
    GLfloat vColor[] = { 0.2f, 0.4f, 0.6f, 1.0f };
    //使用默认光源着色器
    //通过光源、阴影效果跟提现立体效果
    //参数1:GLT_SHADER_DEFAULT_LIGHT 默认光源着色器
    //参数2:模型视图矩阵
    //参数3:投影矩阵
    //参数4:基本颜色值
    shaderManager.UseStockShader(GLT_SHADER_DEFAULT_LIGHT,transformPipeline.GetModelViewMatrix(),transformPipeline.GetProjectionMatrix(),vColor);
    modelViewMatix.PopMatrix();
    torusBatch.Draw();
    glutSwapBuffers();
}
  • 运行结果


    甜甜圈运行结果.png
  • 问题分析:旋转一定的角度后,甜甜圈中不可见的部分与可见的部分混在一起,导致了结果的混乱。在绘制3D场景的时候,我们需要决定哪些部分对观察者是可见的,或者哪些部分对观察者不可见的。对于不可见的部分,应该及早丢弃。例如在一个不透明的墙壁后,就不应该渲染。这种情况叫做“隐藏面消除”。
  • 解决方法:油画算法(不可取);正背面剔除。

2.油画渲染

  • 油画算法:先绘制场景中的离观察者较远的物体,在绘制较近的物体。例如下图:先绘制红色部分,再绘制黄色部分,最后再绘制灰色部分,即可解决隐藏面消除的问题。


    油画算法.png
  • 弊端:无法处理图形叠加一起的情况。


    图形叠在一起.png

3.正面&背面剔除

  • 背面剔除:在渲染的时候,背对观察者的面丢弃,只将正面朝向观察者进行计算。将看不到的面进行剔除,节省CPU和GPU的资源。
  • 正/背面区分


    顶点渲染顺序.png
  • 正面:按照逆时针顶点顺序连接
  • 背面:按照顺时针顶点顺序连接
  • 案例:分析立方体的正背面


    立方体正背面.png

    分析:左侧三角形顶点顺序为:1->2->3;右侧三角形顶点顺序为:1->2->3;当观察者在右侧时,则右边的三角形为逆时针方向,则为正面,而左侧的三角形为顺时针,则为背面;当观察者在左侧时,则左边的三角形为逆时针,则为正面,而右侧的三角形为顺时针,则为背面。
    总结:正面和背面是由三角形的顶点定义顺序和观察者方向共同决定的。随着观察者的角度方向改变,正背面也会改变。

  • 相关代码
    • 开启表面剔除(默认背面剔除)
    void glEnable(GL_CULL_FACE);
    
    • 关闭表面剔除(默认背面剔除)
    void glDissable(GL_CULL_FACE);
    
    • 用户选择剔除哪个面(正面/背面)
    void glCullFace(GLenum mode);
    mode参数为:GL_FRONT,GL_BACK(默认),GL_FRONT_AND_BACK,
    
    • 用户指定顺序哪个为正面
    void glFrontFace(GLenum mode);
     mode参数为:GL_CW,GL_CCW,默认值为GL_CCW
    
  • Demo中开启背面剔除:
//召唤场景
void renderScene(void){
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
    modelViewMatix.PushMatrix(viewFrame);
    //开启背面剔除
    glEnable(GL_CULL_FACE);
    GLfloat vColor[] = { 0.2f, 0.4f, 0.6f, 1.0f };
    shaderManager.UseStockShader(GLT_SHADER_DEFAULT_LIGHT,transformPipeline.GetModelViewMatrix(),transformPipeline.GetProjectionMatrix(),vColor);
    modelViewMatix.PopMatrix();
    torusBatch.Draw();
    glutSwapBuffers();
}

运行结果:


背面剔除后的结果.png

4. 深度测试

  • 深度:深度其实就是该像素点在3D世界中距离摄像机的距离Z值。
  • 深度缓冲区:深度缓冲区就是一块内存区域,专门存储着每个像素点(绘制在屏幕上的)深度值。深度值(Z值)越大离摄像机就越远。在不使用深度测试的时候,如果我们先绘制一个距离比较近的物体,在绘制距离比较远的物体,则距离远的位图因为后绘制,会把距离近的物体覆盖掉。有了深度缓冲区后,绘制物体的顺序就不那么重要了。实际上,只要存在深度缓冲区,OpenGL都会把像素的深度值写入到缓冲区。除非调用glDepthMask(GL_false)来禁止写入。
  • 深度测试:深度缓冲区(DepthBuffer)和颜色缓冲区(ColorBuffer)是对应的。颜色缓冲区存储像素的颜色信息,而深度缓冲区存储像素的深度信息。在决定是否绘制一个物体表面时,首先要将表面的当前像素的深度值与深度缓冲区的值进行比较。如果当前的深度值大于深度缓冲区的值,这丢弃这部分。否者利用这个像素对应的深度值和颜色值,分别更新深度缓冲区和颜色缓冲区。这个过程称为”深度测试“。
  • 深度值计算:
    • 深度值一般由16位、24位或者32位值表示,通常是24位。位数越高,深度的精确值越好。深度值的范围在[0,1]之间,值越小表示越靠近观察者,值越大表示越远离观察者。
    • 在OpenGL中,屏幕空间坐标的Z值即是深度缓冲中的深度值。深度缓冲包含了一个介于0.0和1.0之间的深度值,它将会与观察者视角所看见的场景中所有物体的z值进行比较。我们因此需要一些方法转换这些视图空间z值到[0,1]的范围内,下面的线性方程把z值转换为0.0-1.0之间的值:


      z值转换到0-1.png
  • 使用深度测试:
    • 深度缓冲区,一般由窗口管理系统,GLFW创建。深度值一般由16位、24位和32位值表示。位数越高,深度精确度越好。
    • 开启深度测试:
     glEnable(GL_DEPTH_TEST);
    
    • 在绘制场景前,清除颜色缓存区,深度缓冲区
     glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
    
    • 指定深度测试判断模式
    void glDepthFunc(GLEnum mode);
    
深度测试判断模式.png
  • Demo中开启深度测试:
 glEnable(GL_DEPTH_TEST);

运行结果:


深度测试结果.png

*5. ZFighting闪烁

  • ZFighting闪烁问题原因:开启深度测试后,OpenGL就不会再去绘制模型被遮挡的部分。这样实现的显示更加真实。但是,由于深度缓冲区精度的限制,对于深度非常小的情况下,就可能导致判断深度值不正确,导致深度测试的结果不可预测。显示时交错闪烁。如下图时而渲染出绿色,时而渲染出红色,无法确定,交错闪烁)。


    ZFighting01.png
ZFighting02.png
  • 解决方案:
    • 第一步:启用Polygon Offet方式解决
    //参数列表:
    //GL_POLYGON_OFFSET_POINT 对应光栅化模式:GL_POINT
    //GL_POLYGON_OFFSET_LINE 对应光栅化模式:GL_LINE
    //GL_POLYGON_OFFSET_FILL 对应光栅化模式:GL_FILL
    glEnable(GL_POLYGON_OFFSET_FILL)
    
    • 第二步:指定偏移量
    void glPolygonOffset(Glfloat factor,Glfloat units);
    //应⽤到片段上总偏移计算⽅方程式:
    Depth Offset = (DZ * factor) + (r * units); 
    //DZ:深度值(Z值)
    //r:使得深度缓冲区产生变化的最小值负值,将使得z值距离我们更近,而正值,将使得z值距离我们更远,我们  设置factor和units设置为-1,-1
    
    • 第三步:关闭Polygon Offset
     glDisable(GL_POLYGON_OFFSET_FILL)
    
  • 问题预防:
    • 不要将两个物体靠的太近,避免渲染时三角形叠在一起。这种方式要求对场景中物体插入一个少量的偏移,那么就可能避免ZFighting现象。例如立方体和平面问题中,将平面下移0.001f就可以解决这个问题。当然手动去插入这个小的偏移是要付出代价的。
    • 尽可能将近裁剪面设置的离观察者远一些,会使得整个裁剪范围内的精度变高一些。但是这种方式会使离观察者较近的物体被裁减掉,因此需要调试好裁剪面参数。

6. 窗口、视口、裁剪区域

  • 窗口:就是显示界面。
  • 视口:就是窗口中用来显示图形的一块矩形区域,它可以和窗口等大,也可以比窗口大或小。只有绘制在视口区域中的图形才能被显示,如果图形有一部分超出了视口区域,那么那一部分是看不到的。通过glViewport()函数设置。
  • 裁剪区域(平行投影):就是视口矩形区域的最小最大x坐标(left,right)和最小最大y坐标(bottom,top),而不是窗口的最小最大x坐标和y坐标。通过glOrtho()函数设置,这个函数还需指定最近最远z坐标,形成一个立体的裁剪区域。
  • 裁剪:是OpenGL提高渲染的一种方式,只刷新屏幕上发生变化的部分,OpenGL允许将要进行渲染的窗口只去指定一个裁剪框。基本原理:用于渲染时限制绘制区域,通过此技术可以在屏幕(帧缓冲)指定一个矩形区域。启用裁剪测试之后,不在此矩形区域内的片元被丢弃,只有在此矩形区域内的片元才有可能进入帧缓冲。因此实际达到的效果就是在屏幕上开辟了一个小窗口,可以在其中进行指定内容的绘制。
     //1.开启裁剪测试
     glEnable(GL_SCISSOR_TEST);
     //2.关闭裁剪测试
     gldisable(GL_SCISSOR_TEST);
     //3.指定裁剪窗口 x,y:指定裁剪框左下角位置  width,height:指定裁剪尺寸
     void glScissor(GLint x, GLSize width,GLSize height);
    

裁剪核心代码:

void renderScene(void){
    glClearColor(0.0f, 0.0f, 1.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    //裁剪成红色分区
    //1.设置裁剪区域的颜色
   glClearColor(1.0f, 0.0f, 0.0f, 1.0f);
   //2.设置裁剪尺寸
   glScissor(100, 100, 600, 400);
   //3.开启裁剪测试
   glEnable(GL_SCISSOR_TEST);
   //4.开启清屏,执行裁剪
   glClear(GL_COLOR_BUFFER_BIT);
   
   //裁剪成绿色分区
   //1.设置裁剪区域的颜色
   glClearColor(0.0f, 1.0f, 0.0f, 1.0f);
   //2.设置裁剪尺寸
   glScissor(200, 200, 400, 200);
   //3.开启裁剪测试
   glEnable(GL_SCISSOR_TEST);
   //4.开启清屏,执行裁剪
   glClear(GL_COLOR_BUFFER_BIT);
   //关闭裁剪区域
   glDisable(GL_SCISSOR_TEST);
   
   glutSwapBuffers();
}

7. 颜色混合

  • 混合:在OpenGL中,物体透明技术通常被叫做混合(Blending)。OpenGL渲染时会把颜色值存在颜色缓冲区中,每个片段的深度值也是放在深度缓冲区中。当深度缓冲区被关闭时,新的颜色将简单的覆盖原来颜色缓冲区的颜色值,当深度缓冲区再次打开时,新的颜色片段比原来的值更邻近的裁剪平面时,才会替换原来的颜色片段。
     // 使用OpenGL的混合功能
      glEnable(GL_BLEND);
     // 关闭OpenGL的混合功能
     glDisable(GL_BLEND);
    // 设置混合因子,需要用到glBlendFun函数
    // S:源混合因子
    // D:目标混合因子
    glBlendFunc(GL enum S,GLenum D);
    
  • 组合颜色
    OpenGL 会把源颜色和目标颜色各自取出,并乘以一个系数(源颜色乘以的系数称为“源因子”,目标颜色乘以的系数称为“目标因子”),然后相加,这样就得到了新的颜色。(也可以不是相加,新版本的OpenGL可以设置运算方式,包括加、减、取两者中较大的、取两者中较小的、逻辑运算等)。
    • 目标颜色:已经存储在颜色缓冲区的颜色值(之前的颜色)。
    • 源颜色:当前的要进入颜色缓存区的颜色值(后来要画上去的颜色)。

    也可以理解成源颜色和目标颜色是跟绘制的顺序有关的。假如先绘制了一个红色的物体,再在其上绘制绿色的物体。则绿色是源颜色,红色是目标颜色。如果顺序反过来,则 红色就是源颜色,绿色才是目标颜色。在绘制时,应该注意顺序,使得绘制的源颜色与设置的源因子对应,目标颜色与设置的目标因子对应。(联想射箭 目标是靶心 箭是源)

用数学公式来表达一下这个运算方式:
。则混合产生的新颜色可以表示为:

//Cf:最终计算参数的颜色
//Cs:源颜色
//S:源混合因子
//D:目标混合因子
Cf = (Cs * S) + (Cd * d)
//假设源颜色的四个分量(指红色,绿色,蓝色,alpha值)是(Rs, Gs, Bs, As)
//目标颜色的四个分量是(Rd, Gd, Bd, Ad),
//源因子为(Sr, Sg, Sb, Sa)
//目标因子为(Dr, Dg, Db, Da)
Cf = (Rs*Sr+Rd*Dr, Gs*Sg+Gd*Dg, Bs*Sb+Bd*Db, As*Sa+Ad*Da)

OpenGL混合因子.png

常用混合函数
glBlendFunc(GL_SRC_ALPHA,GL_ONE_MINUS_SRC_ALPHA);

  • 混合的Demo
#include <stdio.h>
#include "GLShaderManager.h"
#include "GLTools.h"
#include <GLUT/GLUT.h>
GLBatch     squareBatch;
GLBatch     redBatch;
GLBatch     blackBatch;
GLBatch     blueBatch;
GLBatch     greenBatcch;
GLShaderManager shaderManager;
GLfloat blockSize = 0.2f;
GLfloat vVerts[] = { -blockSize, -blockSize, 0.0f,
    blockSize, -blockSize, 0.0f,
    blockSize,  blockSize, 0.0f,
    -blockSize,  blockSize, 0.0f};

//重塑函数
void changeSize(int w, int h){
    glViewport(0, 0, w, h);
}

//召唤场景
void renderScene(void){
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
    //定义4种颜色
    GLfloat vRed[] = { 1.0f, 0.0f, 0.0f, 0.5f };
    GLfloat vGreen[] = { 0.0f, 1.0f, 0.0f, 1.0f };
    GLfloat vBlue[] = { 0.0f, 0.0f, 1.0f, 1.0f };
    GLfloat vBlack[] = { 0.0f, 0.0f, 0.0f, 1.0f };
    
    shaderManager.UseStockShader(GLT_SHADER_IDENTITY,vRed);
    redBatch.Draw();
    
    shaderManager.UseStockShader(GLT_SHADER_IDENTITY,vGreen);
    greenBatcch.Draw();
    
    shaderManager.UseStockShader(GLT_SHADER_IDENTITY,vBlue);
    blueBatch.Draw();
    
    shaderManager.UseStockShader(GLT_SHADER_IDENTITY,vBlack);
    blackBatch.Draw();
    
    //固定矩形的颜色
    shaderManager.UseStockShader(GLT_SHADER_IDENTITY, vRed);
    squareBatch.Draw();
    
    //核心代码 颜色混合
    //1.开启混合
    glEnable(GL_BLEND);
    //2.开启组合函数 计算混合颜色因子
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    
    glutSwapBuffers();
    
}

//设置渲染环境
void setupRC(void){
    glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
    shaderManager.InitializeStockShaders();
    
    //绘制移动的矩形
    squareBatch.Begin(GL_TRIANGLE_FAN, 4);
    squareBatch.CopyVertexData3f(vVerts);
    squareBatch.End();
    
    //绘制4个固定矩形
    GLfloat vBlock[] = {
        0.25f, 0.25f, 0.0f,
        0.75f, 0.25f, 0.0f,
        0.75f, 0.75f, 0.0f,
        0.25f, 0.75f, 0.0f
    };
    redBatch.Begin(GL_TRIANGLE_FAN,4);
    redBatch.CopyVertexData3f(vBlock);
    redBatch.End();
    
    GLfloat vBlock1[] = {
        -0.75f, 0.25f, 0.0f,
        -0.25f, 0.25f, 0.0f,
        -0.25f, 0.75f, 0.0f,
        -0.75f, 0.75f, 0.0f
    };
    greenBatcch.Begin(GL_TRIANGLE_FAN,4);
    greenBatcch.CopyVertexData3f(vBlock1);
    greenBatcch.End();
    
    GLfloat vBlock2[] = {
        -0.75f, -0.75f, 0.0f,
        -0.25f, -0.75f, 0.0f,
        -0.25f, -0.25f, 0.0f,
        -0.75f, -0.25f, 0.0f
    };
    blueBatch.Begin(GL_TRIANGLE_FAN,4);
    blueBatch.CopyVertexData3f(vBlock2);
    blueBatch.End();
    
    GLfloat vBlock3[] = {
        0.25f, -0.75f, 0.0f,
        0.75f, -0.75f, 0.0f,
        0.75f, -0.25f, 0.0f,
        0.25f, -0.25f, 0.0f
    };
    blackBatch.Begin(GL_TRIANGLE_FAN,4);
    blackBatch.CopyVertexData3f(vBlock3);
    blackBatch.End();
    
}

//特殊键位函数
void specialKeys(int key, int x, int y){
    
    //每一步移动的距离
    GLfloat stepSize = 0.025f;
    //以左上角的坐标为参照
    GLfloat blockX = vVerts[0];
    GLfloat blockY = vVerts[7];
    
    if (key == GLUT_KEY_UP) {
        blockY += stepSize;
    }
    if (key == GLUT_KEY_DOWN) {
        blockY -= stepSize;
    }
    if (key == GLUT_KEY_LEFT) {
        blockX -= stepSize;
    }
    if (key == GLUT_KEY_RIGHT) {
        blockX += stepSize;
    }
    //边界处理
    if(blockX < -1.0f) blockX = -1.0f;
    if(blockX > (1.0f - blockSize * 2)) blockX = 1.0f - blockSize * 2;;
    if(blockY < -1.0f + blockSize * 2)  blockY = -1.0f + blockSize * 2;
    if(blockY > 1.0f) blockY = 1.0f;
    vVerts[0] = blockX;
    vVerts[1] = blockY - blockSize*2;
    
    vVerts[3] = blockX + blockSize*2;
    vVerts[4] = blockY - blockSize*2;
    
    vVerts[6] = blockX + blockSize*2;
    vVerts[7] = blockY;
    
    vVerts[9] = blockX;
    vVerts[10] = blockY;
    squareBatch.CopyVertexData3f(vVerts);
       
    glutPostRedisplay();
}

//根据空格次数。切换不同的“窗口名称”
void keyPressFunc(unsigned char key, int x, int y){
    
}

//检查OpenGL API是否安全可用
int checkOpenGLInit(void){
   GLenum status = glewInit();
    if(status != GLEW_OK){
        printf("GLEW Error:%s\n",glewGetErrorString(status));
        return 1;
    }
    return 0;
}


int main(int argc,char *argv[])
{
    gltSetWorkingDirectory(argv[0]);
    glutInit(&argc, argv);
    glutInitWindowSize(600, 600);
    //申请一个双缓存区、颜色缓存区、深度缓存区、模板缓存区
    glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_DEPTH | GLUT_STENCIL);
    //创建window的名称
    glutCreateWindow("移动矩形,观察颜色");
    
    //注册回调函数(改变尺寸)
    glutReshapeFunc(changeSize);
    //点击空格时,调用的函数
    glutKeyboardFunc(keyPressFunc);
    //特殊键位函数(上下左右)
    glutSpecialFunc(specialKeys);
    //显示函数
    glutDisplayFunc(renderScene);
    //判断一下是否能初始化glew库,确保项目能正常使用OpenGL 框架
    checkOpenGLInit();
    //绘制
    setupRC();
    //runloop运行循环
    glutMainLoop();
    
    return 0;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,711评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,079评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,194评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,089评论 1 286
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,197评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,306评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,338评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,119评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,541评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,846评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,014评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,694评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,322评论 3 318
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,026评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,257评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,863评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,895评论 2 351

推荐阅读更多精彩内容