Metal框架详细解析(三十四) —— Hello Metal! 一个简单的三角形的实现(一)

版本记录

版本号 时间
V1.0 2018.11.01 星期四

前言

很多做视频和图像的,相信对这个框架都不是很陌生,它渲染高级3D图形,并使用GPU执行数据并行计算。接下来的几篇我们就详细的解析这个框架。感兴趣的看下面几篇文章。
1. Metal框架详细解析(一)—— 基本概览
2. Metal框架详细解析(二) —— 器件和命令(一)
3. Metal框架详细解析(三) —— 渲染简单的2D三角形(一)
4. Metal框架详细解析(四) —— 关于GPU Family 4(一)
5. Metal框架详细解析(五) —— 关于GPU Family 4之关于Imageblocks(二)
6. Metal框架详细解析(六) —— 关于GPU Family 4之关于Tile Shading(三)
7. Metal框架详细解析(七) —— 关于GPU Family 4之关于光栅顺序组(四)
8. Metal框架详细解析(八) —— 关于GPU Family 4之关于增强的MSAA和Imageblock采样覆盖控制(五)
9. Metal框架详细解析(九) —— 关于GPU Family 4之关于线程组共享(六)
10. Metal框架详细解析(十) —— 基本组件(一)
11. Metal框架详细解析(十一) —— 基本组件之器件选择 - 图形渲染的器件选择(二)
12. Metal框架详细解析(十二) —— 基本组件之器件选择 - 计算处理的设备选择(三)
13. Metal框架详细解析(十三) —— 计算处理(一)
14. Metal框架详细解析(十四) —— 计算处理之你好,计算(二)
15. Metal框架详细解析(十五) —— 计算处理之关于线程和线程组(三)
16. Metal框架详细解析(十六) —— 计算处理之计算线程组和网格大小(四)
17. Metal框架详细解析(十七) —— 工具、分析和调试(一)
18. Metal框架详细解析(十八) —— 工具、分析和调试之Metal GPU Capture(二)
19. Metal框架详细解析(十九) —— 工具、分析和调试之GPU活动监视器(三)
20. Metal框架详细解析(二十) —— 工具、分析和调试之关于Metal着色语言文件名扩展名、使用Metal的命令行工具构建库和标记Metal对象和命令(四)
21. Metal框架详细解析(二十一) —— 基本课程之基本缓冲区(一)
22. Metal框架详细解析(二十二) —— 基本课程之基本纹理(二)
23. Metal框架详细解析(二十三) —— 基本课程之CPU和GPU同步(三)
24. Metal框架详细解析(二十四) —— 基本课程之参数缓冲 - 基本参数缓冲(四)
25. Metal框架详细解析(二十五) —— 基本课程之参数缓冲 - 带有数组和资源堆的参数缓冲区(五)
26. Metal框架详细解析(二十六) —— 基本课程之参数缓冲 - 具有GPU编码的参数缓冲区(六)
27. Metal框架详细解析(二十七) —— 高级技术之图层选择的反射(一)
28. Metal框架详细解析(二十八) —— 高级技术之使用专用函数的LOD(一)
29. Metal框架详细解析(二十九) —— 高级技术之具有参数缓冲区的动态地形(一)
30. Metal框架详细解析(三十) —— 延迟照明(一)
31. Metal框架详细解析(三十一) —— 在视图中混合Metal和OpenGL渲染(一)
32. Metal框架详细解析(三十二) —— Metal渲染管道教程(一)
33. Metal框架详细解析(三十三) —— Metal渲染管道教程(二)

开始

首先看一下写作环境

Swift 4.2, iOS 12, Xcode 10

在iOS 8中,Apple发布了自己的GPU加速3D图形API:Metal

Metal类似于OpenGL ES,因为它是用于与3D图形硬件交互的较底层API。

区别在于Metal不是跨平台的。相反,它使用Apple硬件设计得非常高效,与使用OpenGL ES相比,它提供了更高的速度和更低的开销。

在本教程中,您将获得使用Metal API创建一个简单的应用程序的实践经验:绘制一个简单的三角形。在这样做的过程中,您将学习Metal中一些最重要的类,例如设备,命令队列等。

本教程的设计使得任何人都可以看懂它,无论您的3D图形背景如何 - 但是,事情会相当快地进行。如果您确实拥有一些先前的3D编程或OpenGL经验,那么您会发现事情变得更加容易,因为许多相同的概念适用于Metal。

注意:Metal应用程序不能在iOS模拟器上运行;他们需要带有Apple A7芯片或更高版本的设备。要完成本教程,您需要A7 device或更新版本。


Metal vs. SpriteKit, SceneKit or Unity

在开始之前,了解Metal如何与SpriteKitSceneKitUnity等更高级别的框架进行比较会很有帮助。

Metal是一种更接近底层的3D图形API,类似于OpenGL ES,但开销较低意味着性能更好。 它是GPU上方的一个非常薄的层,这意味着,在执行任何操作时,例如将精灵或3D模型渲染到屏幕上,它需要您编写所有代码来执行此操作。 权衡是你有充分的能量和控制力。

相反,像SpriteKitSceneKitUnity这样的高级游戏框架是建立在较低级别的3D图形API(如Metal或OpenGL ES)之上的。 它们提供了您在游戏中通常需要编写的大部分样板代码,例如将精灵或3D模型渲染到屏幕上。

如果您要做的就是制作游戏,那么大多数时候您可能会使用更高级别的游戏框架,如SpriteKit,SceneKit或Unity,因为这样做会让您的生活更轻松。

但是,学习Metal还有两个很好的理由:

  • Push the hardware to its limits - 将硬件推向极限:由于Metal处于如此底层,它可以让您真正将硬件推向极限并完全控制游戏的工作方式。
  • It’s a great learning experience - 这是一个很好的学习经历:Learning Metal教你很多关于3D图形,编写自己的游戏引擎以及更高级别的游戏框架如何工作的知识。

如果其中任何一个听起来像你的理由,请继续阅读!


Metal vs. OpenGL ES

OpenGL ES旨在跨平台。 这意味着您可以编写C ++ OpenGL ES代码,并且大多数情况下,通过一些小的修改,您可以在其他平台上运行它,例如Android。

Apple意识到,尽管OpenGL ES的跨平台支持很好,但它缺少Apple设计其产品的基本原因:著名的Apple集成了操作系统,硬件和软件作为一个完整的整体。

因此,Apple采用了一种洁净室(clean-room)方法,看看它是如何设计专门针对Apple硬件的图形API,目标是降低开销和性能,同时支持最新和最强大的功能。

结果是Metal,与OpenGL ES相比,它可以为您的应用提供高达10✕的绘制调用次数。 这可能会产生一些惊人的效果 - 您可能会记得在WWDC 2014 keynote中的Zen Garden示例中作为示例。

是时候挖掘并查看一些Metal代码了!


开始使用Metal渲染需要的七个步骤

Xcode的iOS游戏模板附带了一个Metal选项,但你不会在这里选择。 这是因为你几乎从头开始组装一个Metal应用程序,这样你就可以理解这个过程的每一步。

打开准备好的工程,在HelloMetal_starter文件夹中打开HelloMetal.xcodeproj。 您将看到一个带有单个ViewController的空项目。

设置Metal需要七个步骤才能开始渲染。 你需要创建一个:

  • MTLDevice
  • CAMetalLayer
  • Vertex Buffer
  • Vertex Shader
  • Fragment Shader
  • Render Pipeline
  • Command Queue

后面我们会一个个的进行有序的讲解和说明。

1. Creating an MTLDevice - 创建一个MTLDevice

您首先需要获得对MTLDevice的引用。

MTLDevice视为您与GPU的直接连接。 您将使用此MTLDevice创建所需的所有其他Metal对象(如命令队列,缓冲区和纹理)。

为此,请打开ViewController.swift并将此导入添加到文件的顶部:

import Metal

这会导入Metal框架,以便您可以在此文件中使用诸如MTLDevice之类的Metal类。

接下来,将此属性添加到ViewController

var device: MTLDevice!

您将在viewDidLoad()而不是初始化程序中初始化此属性,因此它必须是可选的。 由于您知道在使用它之前肯定会对其进行初始化,因此为方便起见,请将其标记为隐式解包的可选项。

最后,添加viewDidLoad()并初始化设备属性,如下所示:

override func viewDidLoad() {
  super.viewDidLoad()
    
  device = MTLCreateSystemDefaultDevice()
}

MTLCreateSystemDefaultDevice返回对您的代码应使用的默认MTLDevice的引用。

2. Creating a CAMetalLayer - 创建一个CAMetalLayer

在iOS中,您在屏幕上看到的所有内容都由CALayer支持。 CALayers的子类用于不同的效果,例如gradient layers, shape layers, replicator layers等。

如果你想用Metal在屏幕上绘制一些东西,你需要使用一个名为CAMetalLayer的特殊CALayer子类。 您将向视图控制器添加其中一个。

首先,将这个新属性添加到类中:

var metalLayer: CAMetalLayer!

注意:如果此时出现编译器错误,请确保将应用程序Target设置为针对与Metal兼容的iOS设备。 如前所述,iOS Simulator目前不支持Metal。

这将为您的新图层存储一个方便的引用。

接下来,将此代码添加到viewDidLoad()的末尾:

metalLayer = CAMetalLayer()          // 1
metalLayer.device = device           // 2
metalLayer.pixelFormat = .bgra8Unorm // 3
metalLayer.framebufferOnly = true    // 4
metalLayer.frame = view.layer.frame  // 5
view.layer.addSublayer(metalLayer)   // 6

下面一步步的进行细分:

  • 1) 创建一个新的CAMetalLayer
  • 2) 您必须指定图层应使用的MTLDevice。 您只需将其设置为您之前获得的设备即可。
  • 3) 将像素格式设置为bgra8Unorm,这是一种奇特的方式,表示“蓝色,绿色,红色和Alpha的8个字节,按顺序排列 - 标准化值介于0和1之间。”这是仅有的两种可用格式之一 一个CAMetalLayer,所以通常你只是保持原样。
  • 4) Apple鼓励您出于性能原因将framebufferOnly设置为true,除非您需要从为此图层生成的纹理中进行采样,或者您需要在图层可绘制纹理上启用计算内核。 大多数情况下,您不需要这样做。
  • 5) 您可以设置图层的frame以匹配视图的frame。
  • 6) 最后,将图层添加为视图主图层的子图层。

3. Creating a Vertex Buffer - 创建一个Vertex Buffer

Metal中的一切都是三角形。 在这个应用程序中,您只是绘制一个三角形,但即使复杂的3D形状也可以分解为一系列三角形。

在Metal中,默认坐标系是标准化坐标系,这意味着默认情况下,您正在查看以(0,0,0.5)为中心的2x2x1立方体。

如果考虑Z = 0平面,则(-1,-1,0)是左下角,(0,0,0)是中心,(1,1,0)是右上角。 在本教程中,您希望使用以下三点绘制三角形:

你必须为此创建一个缓冲区。 将以下常量属性添加到您的类:

let vertexData: [Float] = [
   0.0,  1.0, 0.0,
  -1.0, -1.0, 0.0,
   1.0, -1.0, 0.0
]

这会在CPU上创建一个浮点数组。 您需要将此数据发送到GPU,将其移动到称为MTLBuffer的东西。

为此添加另一个新属性:

var vertexBuffer: MTLBuffer!

然后将此代码添加到viewDidLoad()的末尾:

let dataSize = vertexData.count * MemoryLayout.size(ofValue: vertexData[0]) // 1
vertexBuffer = device.makeBuffer(bytes: vertexData, length: dataSize, options: []) // 2

下面一步步的进行细说:

  • 1) 您需要以字节为单位获取顶点数据的大小。 您可以通过将第一个元素的大小乘以数组中元素的数量来实现此目的。
  • 2) 您可以在MTLDevice上调用makeBuffer(bytes:length:options :)在GPU上创建一个新缓冲区,从CPU传入数据。 您传递一个空数组以进行默认配置。

4. Creating a Vertex Shader - 创建一个顶点着色器

您在上一节中创建的顶点将成为您将要编写的称为顶点着色器的小程序的输入。

顶点着色器只是一个在GPU上运行的小程序,用类似C ++的语言编写,称为Metal Shading Language

顶点着色器每个顶点调用一次,其作用是获取该顶点的信息,例如位置 - 以及可能的其他信息,如颜色或纹理坐标 - 并返回可能修改的位置和可能的其他数据。

为简单起见,您的简单顶点着色器将返回与传入位置相同的位置。

理解顶点着色器的最简单方法是自己查看。 转到File ▸ New ▸ File,选择iOS ▸ Source ▸ Metal File,然后单击Next。 输入Shaders.metal作为文件名,然后单击Create

注意:在Metal中,您可以在单个Metal文件中包含多个着色器。 如果您愿意,还可以将着色器分割为多个Metal文件,因为Metal将从项目中包含的任何Metal文件加载着色器。

将以下代码添加到Shaders.metal的底部:

vertex float4 basic_vertex(                           // 1
  const device packed_float3* vertex_array [[ buffer(0) ]], // 2
  unsigned int vid [[ vertex_id ]]) {                 // 3
  return float4(vertex_array[vid], 1.0);              // 4
}

下面进行详细分解:

  • 1) 所有顶点着色器必须以关键字vertex开头。该函数必须返回(至少)顶点的最终位置。你可以通过指示float4(四个浮点数的向量)来执行此操作。然后给出顶点着色器的名称;稍后您将使用此名称查找着色器。
  • 2) 第一个参数是指向packed_float3数组(三个浮点数的打包向量)的指针 - 即每个顶点的位置。使用[[...]]语法声明属性,您可以使用这些属性指定其他信息,例如资源位置,着色器输入和内置变量。在这里,使用[[buffer(0)]]标记此参数,以指示从Metal代码发送到顶点着色器的第一个数据缓冲区将填充此参数。
  • 3) 顶点着色器还使用vertex_id属性获取特殊参数,这意味着Metal将使用顶点数组内此特定顶点的索引填充它。
  • 4) 在这里,您根据vertex id查找顶点数组内的位置并返回该位置。您还将向量转换为float4,其中最终值为1.0,这是3D数学所必需的。

5. Creating a Fragment Shader - 创建一个片段着色器

顶点着色器完成后,Metal会为屏幕上的每个片段(想象像素)调用另一个着色器:片段着色器。

片段着色器通过插入顶点着色器的输出值来获取其输入值。 例如,考虑三角形底部两个顶点之间的片段:

此片段的输入值将是底部两个顶点的输出值的50/50混合。

片段着色器的工作是返回每个片段的最终颜色。 为了简单起见,您将使每个片段变白。

将以下代码添加到Shaders.metal的底部:

fragment half4 basic_fragment() { // 1
  return half4(1.0);              // 2
}

下面进行详细分解:

  • 1) 所有片段着色器必须以关键字fragment开头。 该函数必须返回(至少)片段的最终颜色。 你可以通过指示half4(四分量颜色值RGBA)来实现。 请注意,half4float4具有更高的内存效率,因为您正在写入更少的GPU内存。
  • 2) 在这里,您返回(1,1,1,1)颜色,即白色。

6. Creating a Render Pipeline - 创建渲染管道

现在您已经创建了顶点和片段着色器,您需要将它们与其他一些配置数据一起组合到一个称为渲染管道(render pipeline)的特殊对象中。

关于Metal的一个很酷的事情是着色器是预编译的,渲染管道配置是在你第一次设置之后编译的。 这使一切都非常有效。

首先,向ViewController.swift添加一个新属性:

var pipelineState: MTLRenderPipelineState!

这将跟踪您即将创建的已编译渲染管道。

接下来,将以下代码添加到viewDidLoad()的末尾:

// 1
let defaultLibrary = device.makeDefaultLibrary()!
let fragmentProgram = defaultLibrary.makeFunction(name: "basic_fragment")
let vertexProgram = defaultLibrary.makeFunction(name: "basic_vertex")
    
// 2
let pipelineStateDescriptor = MTLRenderPipelineDescriptor()
pipelineStateDescriptor.vertexFunction = vertexProgram
pipelineStateDescriptor.fragmentFunction = fragmentProgram
pipelineStateDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
    
// 3
pipelineState = try! device.makeRenderPipelineState(descriptor: pipelineStateDescriptor)

下面进行详细分解:

  • 1) 您可以通过调用device.makeDefaultLibrary()获得的MTLLibrary对象访问项目中包含的任何预编译着色器。 然后,您可以按名称查找每个着色器。
  • 2) 您可以在此处设置渲染管道配置。 它包含您要使用的着色器,以及颜色附件的像素格式 - 即您要渲染的输出缓冲区,即CAMetalLayer本身。
  • 3) 最后,将管道配置编译为管道状态,这种状态在此处有效使用。

7. Creating a Command Queue - 创建命令队列

您需要做的最后一次设置步骤是创建一个MTLCommandQueue

可以将此视为一个有序的命令列表,您可以告诉GPU一次执行一个命令。

要创建命令队列,只需添加一个新属性:

var commandQueue: MTLCommandQueue!

然后,在viewDidLoad()的末尾添加以下行:

commandQueue = device.makeCommandQueue()

恭喜 - 您的一次性设置代码已完成!


Rendering the Triangle - 渲染三角形

现在,是时候转到执行每个帧的代码 - 渲染三角形!

这分五步完成:

  • 1) Create a Display Link
  • 2) Create a Render Pass Descriptor
  • 3) Create a Command Buffer
  • 4) Create a Render Command Encoder
  • 5) Commit your Command Buffer

注意:理论上,这个应用程序实际上并不需要每帧渲染一次,因为三角形在绘制后不会移动。 但是,大多数应用程序都有移动部分,因此您可以通过这种方式来学习该过程。 这也为将来的教程提供了一个很好的起点。

1. Creating a Display Link - 创建一个Display Link

每次设备屏幕刷新时,您都需要一种重绘屏幕的方法。

CADisplayLink是一个与显示刷新率同步的计时器。 这项工作的完美工具! 要使用它,请向该类添加一个新属性:

var timer: CADisplayLink!

viewDidLoad()的末尾初始化它,如下所示:

timer = CADisplayLink(target: self, selector: #selector(gameloop))
timer.add(to: RunLoop.main, forMode: .default)

这会设置您的代码,以便每次屏幕刷新时调用名为gameloop()的方法。

最后,将这些存根方法添加到类中:

func render() {
  // TODO
}

@objc func gameloop() {
  autoreleasepool {
    self.render()
  }
}

在这里,gameloop()只是每帧调用render(),现在只有一个空实现。 是时候充实了这一点。

2. Creating a Render Pass Descriptor - 创建渲染通道描述符

下一步是创建一个MTLRenderPassDescriptor,它是一个对象,用于配置要渲染的纹理,清晰的颜色和其他一些配置。

render()中添加这些行代替// TODO

guard let drawable = metalLayer?.nextDrawable() else { return }
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = drawable.texture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(
  red: 0.0, 
  green: 104.0/255.0, 
  blue: 55.0/255.0, 
  alpha: 1.0)

首先,在之前创建的Metal图层上调用nextDrawable(),它会返回您需要绘制的纹理,以便在屏幕上显示某些内容。

接下来,配置渲染过程描述符以使用该纹理。 您将加载操作设置为Clear,这意味着“在执行任何绘制之前将纹理设置为clear color”,并将clear color设置为要使用的绿色。

3. Creating a Command Buffer - 创建命令缓冲区

下一步是创建命令缓冲区。 将此视为您希望为此帧执行的渲染命令列表。 很酷的事情是,在你提交命令缓冲区之前没有任何实际发生,让你对发生的事情进行细粒度的控制。

创建命令缓冲区很简单。 只需将此行添加到render()的末尾:

let commandBuffer = commandQueue.makeCommandBuffer()!

命令缓冲区包含一个或多个渲染命令。 您将创建下一个。

4. Creating a Render Command Encoder - 创建渲染命令编码器

要创建渲染命令,请使用称为渲染命令编码器的辅助对象。 要尝试这一点,请将这些行添加到render()的末尾:

let renderEncoder = commandBuffer
  .makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
renderEncoder
  .drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3, instanceCount: 1)
renderEncoder.endEncoding()

在这里,您创建一个命令编码器并指定您之前创建的管道和顶点缓冲区。

最重要的部分是对drawPrimitives(type:vertexStart:vertexCount:instanceCount:)的调用。 在这里,您告诉GPU基于顶点缓冲区绘制一组三角形。 为了简单起见,你只画一个。 方法参数告诉Metal,每个三角形由三个顶点组成,从顶点缓冲区内的索引0开始,总共有一个三角形。

完成后,只需调用endEncoding()即可。

5. Committing Your Command Buffer - 提交你的命令缓冲区

最后一步是提交命令缓冲区。 将这些行添加到render()的末尾:

commandBuffer.present(drawable)
commandBuffer.commit()

需要第一行来确保GPU在绘图完成后立即呈现新纹理。 然后提交事务以将任务发送到GPU。

唷! 这是一大堆代码,但是,最后,你已经完成了! 构建并运行应用程序,三角形已完成:

另外,请务必查看Apple的一些优秀资源:

后记

本篇主要讲述了一个简单的三角形的实现,感兴趣的给个赞或者关注~~~~

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

推荐阅读更多精彩内容