iOS 图像渲染过程解析

我们先假设这样一个场景:就是点击一个按键,然后实现一张图片的动画移动。

场景.gif

代码如下:

#import "FJFNineViewController.h"

@interface FJFNineViewController ()
// boxImageView
@property (nonatomic, strong) UIImageView *boxImageView;
@end

@implementation FJFNineViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIButton *tmpButton = [[UIButton alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    tmpButton.backgroundColor = [UIColor redColor];
    [tmpButton addTarget:self action:@selector(tmpButtonClicked:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:tmpButton];
    
    self.boxImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 400, 120, 120)];
    self.boxImageView.contentMode = UIViewContentModeScaleAspectFill;
    self.boxImageView.clipsToBounds = YES;
    self.boxImageView.image = [UIImage imageNamed:@"ic_red_box.png"];
    [self.view addSubview:self.boxImageView];
    
}


#pragma mark -------------------------- Response Event
- (void)tmpButtonClicked:(UIButton *)sender {
    [UIView animateWithDuration:0.5 animations:^{
        self.boxImageView.frame = CGRectMake(300, 80, 80, 80);
    }];
}

@end

一.图形渲染过程

1.视图渲染

  • UIKIt是开发中最常用的框架,可以通过设置UIKit组件的布局以及相关属性来绘制界面,显示和动画都通过Core Anmation.

  • Core Animation 是一个复合引擎,其职责是 尽可能快地组合屏幕上不同的可视内容,这些可视内容可被分解成独立的图层(即 CALayer),这些图层会被存储在一个叫做图层树的体系之中。从本质上而言,CALayer 是用户所能在屏幕上看见的一切的基础。Core Animation依赖于OpenGL ESMetalGPU渲染,Core GraphicsCPU渲染。

  • 最底层是Graphics Hardware 是图形硬件。

image.png

下图是图形渲染的另一种表现形式:

图形渲染技术栈.png

如上图所示:在屏幕上显示视图,需要CPUGPU一起协作。一部数据通过CoreGraphicsCoreImage由CPU预处理。最终通过OpenGL ESMetal将数据传送到 GPU,最终显示到屏幕。

Core Graphics

Core Graphics 基于Quartz 高级绘图引擎,主要用于运行时绘制图像。开发者可以使用此框架来处理基于路径的绘图,转换,颜色管理,离屏渲染,图案,渐变和阴影,图像数据管理,图像创建和图像遮罩以及 PDF 文档创建,显示和分析。
当开发者需要在 运行时创建图像 时,可以使用 Core Graphics 去绘制。与之相对的是 运行前创建图像,例如用 Photoshop 提前做好图片素材直接导入应用。相比之下,我们更需要 Core Graphics 去在运行时实时计算、绘制一系列图像帧来实现动画。

Core Image
Core ImageCore Graphics 恰恰相反,Core Graphics 用于在 运行时创建图像,而 Core Image 是用来处理 运行前创建的图像 的。Core Image 框架拥有一系列现成的图像过滤器,能对已存在的图像进行高效的处理。
大部分情况下,Core Image 会在 GPU 中完成工作,但如果 GPU 忙,会使用 CPU 进行处理。
CoreImage支持CPU、GPU两种处理模式。

2.显示逻辑

  • CoreAnimation提交会话,包括自己和子树(view hierarchy)layout状态等;
  • RenderServer解析提交的子树状态,生成绘制指令;
  • GPU执行绘制指令;
  • 显示渲染后的数据;
image.png

二.提交流程(Commit Transaction)

Core Animation 流水线中,app 调用 Render Server 前的最后一步 Commit Transaction 其实可以细分为 4 个步骤:

  • Layout
  • Display
  • Prepare
  • Commit

Layout

Layout 阶段主要进行视图构建,包括:LayoutSubviews 方法的重载,addSubview: 方法填充子视图等。

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
    }
}

当程序运行main函数的时候,由于传入的principalClassNamenil,那么它的值将从Info.plist去获取,如果Info.plist没有,则默认为UIApplicationUIApplication设置AppDelegate为代理,然后通过代理方法:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
    self.window.rootViewController = [[ViewController alloc] init];
    [self.window makeKeyAndVisible];
    self.window.backgroundColor = [UIColor whiteColor];
    return YES;
}

生成windowwindow设置ViewController实例为rootViewController,因此生成如下所示的视图层级:

image.png

视图树如下所示:

image.png

如上图所示,我们可以看到视图树Xcode所展示的视图层级,多了UIApplication,这里的UIApplication是一个App进程的代表,作为视图树根节点,起到一种起始标志的作用,并不参与视图的处理过程,渲染服务进程是以UIApplication来识别渲染App进程的相关图层。

因为这里涉及到视图的生成,并将视图添加到对应的视图树中,因此会将这些视图标记为待处理,并提交到一个全局的容器中。

这里以tmpButtonboxImageView为例子说一下视图创建:

  UIButton *tmpButton = [[UIButton alloc] initWithFrame:CGRectMake(100, 100, 100, 100)];
    tmpButton.backgroundColor = [UIColor redColor];
    [tmpButton addTarget:self action:@selector(tmpButtonClicked:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:tmpButton];
    
    self.boxImageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 400, 120, 120)];
    self.boxImageView.contentMode = UIViewContentModeScaleAspectFill;
    self.boxImageView.clipsToBounds = YES;
    self.boxImageView.image = [UIImage imageNamed:@"ic_red_box.png"];
    [self.view addSubview:self.boxImageView];

当通过addSubview添加到视图树上面的self.View,这时对应会生成tmpButtonboxImageViewCALayer,同时CALayer对应会存储着
tmpButtonboxImageView设置的相关属性frame、backgroundColord等属性。

Display

Display 阶段主要进行视图绘制,这里仅仅是设置要成像的图元数据。重载视图的 drawRect: 方法可以自定义 UIView 的显示,其原理是在 drawRect: 方法内部绘制寄宿图,该过程使用 CPU内存

CALayer包含一个contents属性指向一块缓存区backing store,可以存放位图(Bitmap)

绘制完成的寄宿图就放在,缓存区的backing store

注意:绘制文本字符串比如说UILabel的文本,会默认调用drawRect来生成寄宿图

image.png

具体详见:iOS 图像渲染原理

由于我们代码并不涉及到调用drawRect来绘制寄宿图,因此不进行分析。

Prepare

Prepare 阶段属于附加步骤,一般处理图像的解码和转换等操作。

因为self.boxImageView.image = [UIImage imageNamed:@"ic_red_box.png"];进行了图片的赋值操作,所以在Prepare 阶段会进行图片数据的解码和图片格式转换操作。

Commit

Commit 阶段主要将图层进行打包(序列化),并将它们发送至 Render Server。该过程会递归执行,因为图层和视图都是以树形结构存在。

runloop执行sorce0操作之前和即将进入休眠(BeforeWaiting)退出(Exit),会通知苹果之前注册Observer监听,回调去执行一个很长的函数: _ZN2CA11Transaction17observer_callbackEP19__CFRunLoopObservermPv()。这个函数里会待处理的图层进行打包,并发送至 Render Server

这里的Core Animation会创建一个OpenGL ES纹理,并确保在boxImageView图层中的位图被上传到对应的纹理中。
同样当你重写-drawInContext方法时,Core Animation会请求分配一个纹理,同时确保Core Graphics会将你在-drawInContext中绘制的东西放入到纹理的位图数据中。

动画渲染原理

- (void)tmpButtonClicked:(UIButton *)sender {
    [UIView animateWithDuration:0.5 animations:^{
        self.boxImageView.frame = CGRectMake(300, 80, 80, 80);
    }];
}

iOS 动画的渲染也是基于上述 Core Animation 流水线完成的。这里我们重点关注appRender Server 的执行流程。

日常开发中,如果不是特别复杂的动画,一般使用 UIView Animation 实现,iOS将其处理过程分为如下三部阶段:

  • Step 1:调用animationWithDuration:animations: 方法
    -Step 2:Animation Block 中进行Layout,Display,Prepare,Commit等步骤。
  • Step 3: Render Server 根据 Animation 逐帧进行渲染。
image.png

这里只进行一次将图层树CAAnimation对象提交到渲染服务进程,然后由渲染服务进程根据CAAnimation参数图层树信息,去渲染动画过程中的每一帧。

三.渲染服务(Render Server)

Render Server.png
  • 渲染服务进程首先将打包上来的图层进行解压(反序列化),得到图层树

  • 然后依据图层树中图层的顺序、RGBA值图层的frame等,对被遮挡的图层进行过滤。比如说视图A视图B上面,视图A背景色是不透明的,视图A遮挡了视图B一部分,这样视图B渲染服务进程这里会被CoreAnimation过滤掉视图B被遮挡的那部分。

  • Core Animation进行过滤以后将图层树转化为渲染树

渲染树就是指图层树对应每个图层的信息,比如顶点坐标、顶点颜色这些信息,抽离出来,形成的树结构,就叫渲染树了

  • 然后将渲染树信息递归提交给OpenGL ES /Metal

  • OpenGL ES /Metal会编译、链接可编程的顶点着色器和片元着色器程序(如果有对顶点着色器和片元着色器进行自定义),并结合固定的渲染管线,生成绘制命令,并提交到命令缓冲区CommandBuffer `,供GPU读取调用。

四. 图形渲染管线(Graphics Rendering Pipeline)

OpenGL ES /Metal的作用是通过它提供给我们的API,最终在CPU上生成GPU可以理解的一系列指令(Commands),然后提交给GPU去执行它。

Graphics Rendering Pipeline,图形渲染管线,实际上指的是一堆原始图形数据途经一个输送管道,期间经过各种变化处理最终出现在屏幕的过程。通常情况下,渲染管线可以描述成 vertices(顶点)pixels(像素) 的过程

无论是OpenGL ES /Metal都有图形渲染管线这个概念,它是各类图形API通用的一个概念。

图形渲染管线的主要工作可以被划分为两个部分:

  • 3D 坐标转换为2D 坐标
  • 2D 坐标转变为实际的有颜色的像素

GPU 图形渲染管线的具体实现可分为六个阶段,如下图所示。

  • 顶点着色器(Vertex Shader)
  • 形状装配(Shape Assembly),又称 图元装配
  • 几何着色器(Geometry Shader)
  • 光栅化(Rasterization)
  • 片段着色器(Fragment Shader)
  • 测试与混合(Tests and Blending)

我们以最经典的三角形绘制为例,如下是一个典型的管线处理过程:

image.png

1.顶点着色器

顶点着色器对每个顶点执行一次运算,它可以使用顶点数据来计算该顶点的坐标,颜色,光照,纹理坐标等,在渲染管线每个顶点都是独立地被执行。

每个顶点都对应一组顶点数组,可以激活(启用)最多可达8个数组,每个数组用于存储不同类型的数据:顶点坐标、表面法线、RGBA颜色、辅助颜色、颜色索引、雾坐标、纹理坐标以及多边形的边界标志等。

如果设置了相关的属性就会激活相关的数组,比如tmpButton激活了顶点坐标、RGBA颜色boxImageView激活了顶点坐标、纹理坐标

接下来对顶点坐标进行变换,应用程序中设置的图元顶点坐标通常是针对本地坐标系的,本地坐标系简化了程序中的坐标计算,但是GPU 并不识别本地坐标系,所以在顶点着色器中要对本地坐标执行如下变换。

image.png
  • 模型变换—— 从模型坐标系到世界坐标系
    模型坐标系(局部坐标系):
    image.png

世界坐标系:

image.png

  • 视变换—— 从世界坐标系到相机坐标系
    世界坐标系到相机坐标系:
image.png
  • 投影变换 —— 从相机坐标到裁剪坐标系
    正投影:
    image.png

透视投影:

image.png
  • 透视除法 —— 从裁剪坐标系到规范化设备坐标系(去除W)
    W = 3:
    image.png

W = 1:

image.png
  • 视口变换 —— 从规范化设备坐标系到屏幕坐标系

视口变换是在投影变换之后,将空间中的物体变换到视口中:

image.png

引用自:OpenGl从零开始之坐标变换

2.形状(图元)装配

该阶段将顶点着色器输出的所有顶点作为输入,并将所有的点装配成指定图元的形状。图中则是一个三角形图元(Primitive) 用于表示如何渲染顶点数据,如:点、线、三角形

这里很关键的一点就是,在顶点着色器程序输出顶点坐标之后,各个顶点被按照绘制命令中的图元类型参数,以及顶点索引数组被组装成一个个图元。

Metal 中,支持的图元类型如下,也是由点,线,三角形组成。

typedef NS_ENUM(NSUInteger, MTLPrimitiveType) {
    MTLPrimitiveTypePoint = 0,
    MTLPrimitiveTypeLine = 1,
    MTLPrimitiveTypeLineStrip = 2,
    MTLPrimitiveTypeTriangle = 3,
    MTLPrimitiveTypeTriangleStrip = 4,
} NS_ENUM_AVAILABLE(10_11, 8_0);
image.png

同样的OpenGL支持图元类型如下:

image.png
image.png

每个图元由一个或者多个顶点组成,每个顶点定义一个点,一条边的一端或者三角形的一个角。每个顶点关联一些数据,这些数据包括顶点坐标,颜色,法向量以及纹理坐标等。所有这些顶点相关的信息就构成顶点数据

这里由于顶点数据较多,因此性能更高的做法是,提前分配一块显存,将顶点数据预先传入到显存当中。这部分的显存,就被称为顶点缓冲区

另外,在绘制图像时,总是会有一些顶点被多个图元共享,而反复对这个顶点进行运算常常是没有必要的(也有某些特殊场景需要)。因此对通过索引数据,指示OpenGL绘制顶点的顺序,不但能防止顶点的重复运算,也能在不修改顶点数据的情况下,一定程度的重新组合图像。

顶点数据一样,索引数据也可以以索引数组的形式存储在内存当中,调用绘制函数时传入;或者提前分配一块显存,将索引数据存储在这块显存当中,这块显存就被称为索引缓冲区。同样的,使用缓冲区的方式,性能一般会比直接使用索引数组的方式更加高效。

举个例子:
比如生成一个正方形,会生成如下的顶点坐标数组和索引数组:

image.png

显存,也被叫做帧缓存,它的作用是用来存储显卡芯片处理过或者即将提取的渲染数据。如同计算机的内存一样,显存是用来存储要处理的图形信息的部件。

3. 几何着色器。

该阶段把图元形式的一系列顶点的集合作为输入,它可以通过产生新顶点构造出新的(或是其它的)图元来生成其他形状。例子中,它生成了另一个三角形。

通俗来讲:几何着色器就是提供图元相互之间的连接信息,将原本独立的图元连接起来。

如下图所示:

image.png

注意:几何着色器是一个可选的阶段,比如我们创建的tmpButton,在图元装配阶段,就可以根据顶点坐标、索引值、图元类型(GL_QUADS)就可以确定这是一个正方形,就无需再经过几何着色器这个阶段。

4.光栅化

光栅化阶段,基本图元被转换为供片段着色器使用的片段(Fragment)Fragment 表示可以被渲染到屏幕上的像素,它包含位置,颜色,纹理坐标等信息,这些值是由图元的顶点信息进行插值计算得到的。这些片元接着被送到片元着色器中处理。这是从顶点数据到可渲染在显示设备上的像素的质变过程

片段着色器运行之前会执行裁切(Clipping)裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。

image.png

红色区域即表示真正会进入片段着色器 (Fragment function) 中进行处理的片段。

5.片段着色器

片段着色器的主要作用是计算每一个片段最终的颜色值。

可编程的片段着色器是实现一些高级特效如纹理贴图,光照,环境光,阴影等功能的基础,这就是最精彩的部分。

在片段着色器之前的阶段,渲染管线都只是在和顶点,图元打交道。而在 3D 图形程序开发中,贴图是最重要的部分,我们的 Resources,可以包含纹理等数据,这些纹理可以被片段着色器使用。片段着色器可以根据顶点着色器输出的顶点纹理坐标对纹理进行采样,以计算该片段的颜色值。从而调整成各种各样不同的效果图。

另外,片段着色器也是执行光照等高级特效的地方,比如可以传给片段着色器一个光源位置光源颜色,可以根据一定的公式计算出一个新的颜色值,这样就可以实现光照特效

6. 测试与混合

测试:

在着色器程序完成之后,我们得到了像素数据。这些数据必须要通过测试才能最终绘制到画布,也就是帧缓冲上的颜色附着上。

测试主要可以分为像素所有者测试(PixelOwnershipTest)、裁剪测试(ScissorTest)、模板测试(StencilTest)和深度测试(DepthTest),执行的顺序也是按照这个顺序进行执行。

  • 最开始进行的测试是像素所有者测试,主要是剔除不属于当前程序的像素运算。

  • 之后裁剪测试,主要是剔除窗口区域之外的像素。这两个测试都是由OpenGL/Metal内部实现的,无需开发者干预。

  • 模板测试是通过模板测试程序去决定最终的像素是否丢弃,同样也是根据OpenGL / Metal的模板覆写状态决定是否更新像素的模板值。模板测试给开发者提供了高性能的裁剪方案, 三维物体 的描边技术,就是 模板测试 典型的用处之一。

  • 深度测试,主要是通过对像素的运算出来的深度,也就是像素离屏幕的距离进行对比,根据OpenGL / Metal设定好的深度测试程序,决定是否最终渲染到画布上。一般默认的程序是将离屏幕较近的像素保留,而将离屏幕较远的像素丢弃。如果像素最终被渲染到画布上,根据设定好的OpenGL / Metal深度覆写状态,可能会更新帧缓冲区深度附着的值,方便进行下一次的比较。

混合:

在 测试阶段 之后,如果像素依然没有被剔除,那么 像素的颜色 将会和 帧缓冲区 中颜色附着上的颜色进行混合, 混合的算法可以通过 OpenGL/ Metal的函数进行指定。但是OpenGL/ Metal提供的混合算法是有限的,如果需要更加复杂的混合算法,一般可以通过像素着色器进行实现,当然性能会比原生的混合算法差一些。

抖动:
混合阶段过后,根据OpenGL/Metal的状态设置,会决定是否有抖动这个阶段。

抖动是一种针对对于可用颜色较少的系统,可以以牺牲分辨率为代价,通过颜色值的抖动来增加可用颜色数量的技术。抖动操作是和硬件相关的,允许程序员所做的操作就只有打开或关闭抖动操作。实际上,若机器的分辨率已经相当高,激活抖动操作根本就没有任何意义。默认情况下,抖动是激活的。

这里由于demo中的UIWindow被设置为白色、UIViewControllerview没有设置背景色,tmpButton为红色、boxImageView加载图片,图片没有设置alpha,所以这里被视图都是不透明(UIWindow、tmpButton、boxImageView)或者完全透明(self.View)因此在渲染服务进程处理阶段,会根据图层树中图层顺序、图层位置、图层的RGBA值,进行过滤,因此最后留下的图层RGBA值,就是要显示屏幕的RGBA值。因此这里并不涉及到图层颜色的混合。

经历了测试和混合后,帧缓冲区绑定的颜色缓冲区就是最终要显示到屏幕上面的颜色值。

五.屏幕显示

image.png

通常来说,计算机系统中CPU、GPU、显示器是以上面这种方式协同工作的。

CPU 计算好显示内容提交到 GPUGPU 渲染完成后将渲染结果放入帧缓冲区

随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示。

image.png

iOS 设备会始终使用双缓存,并开启垂直同步。
由于垂直同步的机制,如果在一个 VSync 时间内,CPU或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

双缓冲工作原理:GPU 会预先渲染一帧放入一个缓冲区中,用于视频控制器的读取。当下一帧渲染完毕后,GPU 会直接把视频控制器的指针指向第二个缓冲器

image.png

这里介绍屏幕图像显示的原理,需要先从 CRT 显示器原理说起,如下图所示。
CRT 的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。当电子枪换行进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync;而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync 信号产生的频率。虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。

image.png

引用自:计算机那些事(7)——图形图像渲染原理

阅读延伸:

Metal入门教程总结
Metal【1】—— 概述
深入理解RunLoop
OpenGL全流程详细解读
iOS 图像渲染原理
iOS开发-视图渲染与性能优化
计算机那些事(7)——图形图像渲染原理

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

推荐阅读更多精彩内容