【GDC 2018】Water Rendering in FarCry 5

这里分享的是Far Cry5在GDC 2018上分享的他们水体的渲染实现方案,照例对渲染方案的要点做一下总结:

  1. 整个实现方案分为引擎层(负责数据的管理,包括生成、查询等),渲染层(负责渲染)以及工具层(提供编辑能力)三部分
  2. 局部水体(河流等)由四叉树结构实现
  3. 通过flowmap实现水体的流动效果
  4. displacement的计算在GPU完成,但是需要回读到CPU用于游戏交互
  5. 支持面向PCG的水体编辑能力(样条曲线)
  6. 采用类似Projected Grid的屏幕空间曲面细分来实现mesh(四叉树只是用于存储数据),屏幕空间pass的输入是一个低模的mesh(大致的形状)与材质(指导曲面细分)
  7. 材质会通过一个compute shader写入到一个structure buffer,buffer尺寸跟屏幕空间像素数目一致
  8. 水体(低模mesh)会被切割成一个个的tile,近景跟远景tile可见性判断方式会有所不同(近景用occlude query,远景用的是HZB Query),经过剔除后,远景部分会通过一个DP完成绘制,近景则需要更多的DP
  9. 经过上一步的绘制之后,会得到一个mini G-Buffer,存储了水体的数据(带有多个后续计算需要用到的数据)、法线跟深度等三个RT
  10. 水体的交互效果是基于粒子实现的,将粒子的box decal投影到水面上生成一个交互buffer
  11. 波形数据采用了FBM实现了高低频混合,并通过噪声对法线做了扰动来规避重复
  12. 波形做了LOD优化,近景需要9个FBM采样,远处则只用3个
  13. 做完上述准备后,就要进入最后的屏幕空间pass了,为了避免屏幕pass的浪费,还会增加一个额外pass用于对屏幕空间划分后的tile的有效性进行判断,只绘制那些有水体的tile
  14. 为了避免projected grid的顶点闪烁问题,这里曲面细分得到的顶点密度接近像素密度,会导致硬件在执行层面的低效,后续顶点计算会考虑挪动到CS中
  15. 因为是屏幕空间的shading,因此还需要自行生成水面的法线、粗糙度等数据,以得到更为真实的反光效果
  16. foam是通过对贴图采样实现,没有采用雅克比矩阵的计算数值,而是通过一个噪声贴图调制得到
  17. 针对性能,这里尝试了min16float的方式优化了VGPR的消耗(HLSL&GLSL都支持),并对寄存器压力的原因与优化方式做了解释,最终GPU的单帧消耗为1.5ms
Page 001
Page 002

大纲:

  1. 前作中的方案简介
  2. Montana(蒙大拿)区域的水体效果总览(需求)
  3. 总体的方案目标
  4. 单帧绘制流程
  5. 性能优化
  6. 问题与方案汇总
Page 003

Far cry 2,老式blinn-phong specular渲染

Page 004

Far cry 3,场景PBR,水体还是非PBR的

Page 005

Far Cry 4,加了法线对反射的扰动

Page 006

Far Cry Primal

Page 007

Far Cry5的效果:PBR水、带了flowmap效果、反射、折射效果等

Page 008
Page 009
Page 010
Page 011
Page 012
Page 013

作者对蒙大拿区域的水体效果用真实照片的形式做了汇总,这也是Far Cry5尝试达到的效果,和具备的能力(倾斜水体,支持flowmap、foam跟瀑布)

Page 014

针对水体而言,总体来说有如下几方面的需求:

  1. 引擎层:提供数据生成、查询、贴图streaming能力
  2. 工具层:给美术同学提供制作、编辑能力
  3. 渲染层:负责完成数据的可视化展示
Page 015

引擎层的数据做进一步的细拆:

  1. 要支持通过一个简单的接口实现快速查询,提到了两方面的数据:
  • 用bitfield标注有效性的四叉树结构,用于支持动态水体
  • 离线烘焙的水体高度图(湖泊、河流、瀑布等水体需要),分块存储,支持streaming
  1. 提供水体流动与物理数据
  • flowmap要支持streaming,在GPU进行计算,但是要回读到CPU用于游戏逻辑
  1. 材质数据
  • 需要提供提前烘焙好的材质map
Page 016

这里展示了几个水体查询的接口,包括水体高度、流向信息的接口

Page 017

工具层的要求:

  1. 易用
  2. 易迭代
  3. 提供程序化能力
Page 018

这个视频展示了程序化工具的能力:

  1. 支持闭合样条曲线创建湖泊
  2. 支持开放样条曲线(带宽度)创建河流
  3. 生成水体的时候,会顺带将底部的材质、配套的一些物件做有选择性的生成
Page 019

渲染层的要求:

  1. 需要支持屏幕空间的曲面细分(屏幕空间的意义是避免全图细分带来的高消耗)
  2. 要支持逐个像素的材质混合
  3. 尽量使用async compute能力
  4. 支持带foam的flowmap
Page 020
Page 021
Page 022
Page 023
Page 024
Page 025
Page 026
Page 027
Page 028
Page 029

这里展示了Far Cry 5的水体效果,包含了水上、水下、江河湖海等各个层面。

下面来看下如何实现屏幕空间的Tessellation。

Page 030

总的来说,美术同学提供的输入有两个:

  1. 一个低分辨率的mesh
  2. 一个材质,用于指导上述mesh该如何Tessellate

之后就会创建一个屏幕空间的Tessellation mesh,如图左边所示

Page 031

美术同学提供的材质,其实就包含了一系列的参数,比如振幅、粗糙度、速度、scale等,这些数据后面会被烘焙到一个structure buffer中(如图右边所示)

Page 032

不过如果我们用DX11来实现的话,会遇到一个问题,即我们不能将每个像素需要用的贴图存储到这个buffer中(?也不应该这样用吧)

这里的方式是将贴图存储到array中,之后将每个像素用的贴图转化为贴图的index

Page 033

当角色在世界中漫游的时候,可以想象,在这个过程中,会需要将各个位置用到的材质加载进来,为了能够高效的处理各个材质,这里做了一个indirection操作。

即会通过一个compute shader将所有的材质数据取出来,存储到一个很大的structure buffer中(即上图中的Water Material Buffer),之后我们在渲染或计算的时候,就不用再额外采样这些材质。

这里也可以推断出,上述buffer的尺寸与材质的数目是一一对应的,而每个像素(屏幕空间?)则需要存储该像素所对应的材质在这个buffer中的index,之后运算的时候就可以只采样这个buffer即可(贴图咋处理?前面说过,只需要将贴图的index存在上述材质buffer中即可)

Page 034

接下来看看整体的渲染流程是怎样的,流程图如上所示,下面对每个阶段做分别介绍。

Page 035

首先要做的就是标注出水体在哪些像素上是可见的,这里需要针对近景跟远景做分别处理,之所以要分开,是因为近景mesh精度要求高,且覆盖面积大,采用不同的算法可以同时保证精度与性能。

Page 036

近景采用conditional rendering方案(?),通过遮挡查询来判断哪些water mesh instance是可见的,为了降低消耗,这里直接采用mesh instance的AABB取代直接的mesh,之后存储下每个mesh instance的查询结果。

Page 037

远景的mesh是按照四叉树的结构组织的,有两种类型,分别是平面水体与带有高低起伏的水体(瀑布等)。

整个计算过程是发生在GPU(Compute Shader)上的,以sector(四叉树的一个节点)为单位进行,使用sector的AABB(有高低起伏的,要从高度图中获取数据)对遮挡buffer(类似于HZB)进行可见性判断计算,之后基于可见性构建远景mesh的draw indirect arguments buffer。

Page 038
Page 039

通过遮挡剔除,我们可以尽可能的降低需要绘制的消耗:

  1. DP
  2. 面数等

远景的部分,会合并成一个DrawCall,而近景由于绘制精度要求高,难以合并(?),通常会需要用多个DrawCall来绘制

Page 040

如果想要将所有的计算都放到屏幕空间,那我们就需要一个mini G-Buffer,这个G-buffer的分辨率要求低,但是FOV要求高(考虑displacement)

Page 041

前面说过,美术同学给的输入是一个低分辨率的mesh,针对这些mesh,我们执行一次position pass,得到mini GBuffer。

G-buffer总共需要上图所示的三类数据(包含近景跟远景):

  1. Data:
  2. Mesh的法线数据:
  3. Water Mesh的深度数据:
Page 043

下面看看三张贴图里都存了啥。

  1. Data贴图存储了:
  • 8bit的shader ID,指示了材质的一些参数,是否front face,是否支持光照,是否是有效像素等
  • 8bit的材质structure buffer index,其实就是前面介绍的材质structure buffer的index
  • 8bit的两个miplevel:algae & foam。用于后续采样两类贴图的时候作为输入(因为屏幕空间计算就没有办法通过DDX/DDY获取贴图的miplevel了,所以需要提前计算给到)。
Page 044

因为水体是可以交互的,所以这里还需要考虑交互的输入。

这里的实现方法相对简单,就是采用粒子实现,每个粒子都会生成一个投影的box decal,这个decal在绘制的时候,需要转换到mesh instance的object space,从而将数据正确应用到贴图上

Page 045

这里会剔除掉屏幕外的像素(旋转视角的话,缺失了此前的数据会不会导致效果异常?),并对displacement贴图(decal的)进行采样,之后应用水体效果的一些动画逻辑与数据(如splash等多帧效果需要)。

为了实现新增的粒子的displacement跟已有displacement的平滑衔接,需要对box decal的displacement做一个沿着边沿的fade。

最后,针对多个decal,这里采用了max alpha blend的算法来实现混合。

Page 046

最后得到了包含所有splash的displacement结果

Page 047

为了避免重复,这里会通过噪声来对法线等数据做扰动,同时通过FBM算法来混合高低频信息,得到更为真实的效果表现。

在目前的设计中,总共需要叠加9个octave,但是这样消耗就太高了,这里的优化方案是增加LOD策略,近处用9个,远处就只有3个(不降低到0个的原因是,这样会导致反射效果的极端异常)。

为了节省性能,displacement 贴图不用做clear,因为会有另一张贴图指示哪些区域是有效的,有效的数据会被覆写,所以不必要clear。

Page 048

在进入屏幕空间Tessellation之前,还需要做一次剔除,去掉那些不可见的部分。

Page 049

这里将整个屏幕划分成一个个的小tile,每个tile的尺寸为32(测试发现这个尺寸对所有硬件都是最佳的?)。

剔除需要给出两个结果:

  1. tile是否覆盖水体
  2. 每个tile中有效像素的数目(何用?)
Page 050

这里给出了计算tile中有效数目的详细说明,需要注意的是,如果是在主机或者DX12的平台上,可以通过WaveActiveBallot指令,将8x8(一个group)个原子指令减少到1个。

结果存入一个structure buffer中。

Page 052

之后会用另一个pass来获取上述structure buffer,并判断当前tile是否有数据(只要大于0的话,为啥要统计像素数?),有的话,就将这个tile放到待绘制列表里:

  1. 对indirect draw arguments buffer加一
  2. 将tile ID(dispatchThreadId.x)写入到第二个structure buffer中(waterTileDataOutput)

截图右上角显示,当前帧的水体有606个mesh instance,每个带有1536个索引(顶点),可以看出是经过高度细分的。

Page 053

经过上述处理之后,就知道屏幕空间哪些tile要绘制水体,要做Tessellation。

Page 054

接下来开始进行Tessellation逻辑,上图给出了mesh的顶点密度,针对每个quad会做曲面细分。

Page 055

这里通过indirect draw一次性完成所有tile的绘制与曲面细分。这里没有对近远景做额外处理,采用的是常量的Tessellation密度。

Page 056

Tessellation完后,我们就得到了一个较为密集的顶点(这也是为什么采用了类似projected grid的方案,却不会有明显跳变的原因,因为顶点密度接近像素,而逐像素的displacement是不会有跳变的),接下来看看针对每个顶点,要做哪些计算:

  1. 获取水体的深度,转换到世界空间的坐标
  2. 采样该点的displacement数据,包括水体自带的FBM displacement,以及交互粒子的displacement
  3. 通过Nan的方式将无效的顶点剔除掉(VS剔除的唯一方法)
  4. 将displacement应用后的坐标投影回屏幕空间,并将该点对应的uv写入到RT中(用于实现大FOV到小FOV RT的映射)
  5. 通过深度测试来判断可见性
Page 057
Page 058
Page 059
Page 060
Page 061
Page 062
Page 063
Page 064
Page 065

大FOV是为了避免水体的displacement将水体收缩,导致边缘数据缺失

Page 066

经过大FOV到小FOV的映射采样之后,就能得到正确结果(虽然这个映射会带来一些扭曲,但是在相机移动的情况下,基本上可以忽略)

Page 067

上面介绍了mini G-Buffer的延迟管线计算逻辑,接下来看看法线数据。

Page 068

前面说了,美术同学是不用提供法线贴图的,这里是通过算法生成一张屏幕空间的法线贴图:

  1. 通过深度贴图计算得到
  2. 会根据相机到水体的距离,来调整参与计算的四个位置的offset从而实现精度的自适应
  3. 为了避免法线的跳变,这里还会将高频的displacement normal跟mesh(美术同学会提供一个低精度的mesh)的normal做一次混合
Page 069

除了法线,为了得到正确的屏幕空间高光效果,还需要生成屏幕空间的光滑贴图:

  1. 计算出每个像素的高斯法线(跟前面的法线的区别在于?)
  2. 最后基于法线的variance(方差)计算得到光滑度(或者就直接取方差作为光滑度,方差大说明周边法线扰动大,也就意味着不够光滑)
Page 070
Page 071
Page 072

基于光滑度以及前面的逐像素的材质数据,就可以在屏幕空间中得到普通绘制方式的高光效果

Page 073

最后来看看foam

Page 074

大致的实现思路是用一张噪声贴图来对foam贴图进行调制(放弃如FFT中的物理计算了?)。

这里一个较为复杂的点,是需要注意与flowmap的结合(没讲具体如何跟foam结合)。

foam的显示位置是通过SDF控制的,而SDF则是提前烘焙的,会采集海岸线、露出水面的物件等信息。

foam主要有两种:

  1. 近岸海浪的foam
  2. displacement foam

最终的foam是两者的叠加混合。

Page 075

flowmap的烘焙是离线完成的

Page 076

基于地形跟水平面,通过flood-fill算法生成(从水源点开始,寻找水的流动方向,将结果写入flowmap)。

Page 077

如果直接上flood-fill不考虑地形的高低落差的话,会出现水流效果跟真实情况存在差距的问题(如上图所示),为了避免这种情况,这里会取用SDF的数据对flood-fill算法进行约束。

Page 078

最终得到的flowmap是一个atlas,存储的是近景的flowmap数据,分辨率较高,会需要走Streaming(GPU通过VT的方式进行取用)

Page 079

远景的数据则是一张覆盖全图的低分辨率贴图。

Page 080

这是覆盖全图的heightmap数据,也是离线烘焙的,一个像素覆盖8m范围。

Page 081

最后看看这些数据如何组装

Page 082

前面我们提到了,这里我们可以拿到带水和不带水的depth map,同时还可以拿到各个像素的material id,基于这些数据,我们就可以对各个像素做shading了。

这里采用的是Forward+的光照shading计算逻辑:

  1. 会基于上述数据,计算间接光,包括ambient(通过GI获取),反射(通过环境贴图与SSR方式得到)等数据
  2. 还会计算直接光照
  3. 计算局部光照
  4. 计算exposure光(?)

材质还支持混合,如上图右边所示,可以支持两种材质之间的混合,这个主要用于解决两种不同的水体的混合区域的过渡效果,这里的blend只会针对一些关键参数,其他参数的blend不会对效果造成过大影响,因此就不用额外浪费算力了。

Page 083
Page 084
Page 085

为了得到较好的光照效果,这里还做了一些额外的处理:

  1. 没有给美术同学提供一种直接控制颜色的手段,而是将散射系数暴露给他们进行控制
  2. 如上图所示,共12个参数,对应12种类型的水体,美术同学只需要从table中选择所需要的水体类型(或者创建新的)即可
  3. 每个类型就带一个参数,RGB颜色与最后的浑浊度(turbidity),基于这些参数可以创建任意类型的水体效果(干净的、浑浊的、海洋的、河流的)
Page 086

最后还需要采样foam等贴图,接下来看看这些贴图采样后的效果

Page 087

这是添加了折射之后的light transport效果

Page 088

叠加SSLR(screen space local reflection)效果

Page 089

环境贴图反射效果

Page 090

叠加所有效果后的表现,包括foam、反射、折射、splash粒子、涟漪等效果。不过要想得到这个效果,会需要做非常繁重的VGPR(vector general purpose register)计算,下面来看看怎么优化这部分的消耗。

Page 091

首先就是调整部分属性的精度,如上图所示,可以节省9个VGPR调用。那么min16float是什么格式呢?

Page 092

这是HLSL的一种基本类型(跟half是不一样的):

  1. 使用这种类型,编译器就知道其数据精度是可以降低的
  2. 存储在buffer中的数据还是全精度的,只是在采样的时候,GPU会自动将之转换为16位的(那存储全精度的意义在哪里)
  3. 但是这里并不是一定会需要采用低精度的(所以这就是存储全精度的意义,那么什么情况下会用低精度的?由软件跟硬件stack决定,具体一点呢?开发者基于算法的可能性来评估,并由QA同学测试确认)
  4. 支持整型与浮点型
  5. GLSL也有类似的字段
Page 093

哪些地方需要全精度的数据:

  1. 贴图采样的UV坐标,精度不足容易导致块状效果
  2. 法线向量,精度不足,光照结果会非常低质量
    3.两个接近相等的数值的相减结果
  3. 除以接近于0的数
  4. 存在累计误差的地方
Page 094

寄存器占用数目过高也会对shader的执行效率产生较大的影响,这也是日常GPU瓶颈的一个很重要的原因。

一个常用的手段是提高GPU的Occupancy(为啥提高Occupancy能够降低寄存器的压力?),Occupancy的解释是:

当前SIMD组件待执行的WaveFronts的数目与SIMD可以分配的最大slot数目的比值

这个数值越大,意味着SIMD可以调度的空间越大,当某个wavefront执行阻塞时,就可以调整到另一个Wavefront上继续执行以提升SIMD的执行效率,减少整体执行的耗时。

Occupancy会有助于增加VGPR(寄存器) usage的discrete threshold,如果VGPR压力过大的话,通过这种方式可以得到更大的收益。

Page 095

寄存器压力有这么几个主要的原因:

  1. 过早的读取了内存数据,并持续占用
  2. 循环的unrolling
  3. 大量的中间变量
Page 096

寄存器分配的时候也会可能分配了过大的寄存器:

  1. 数据的通道过多:
  • 多个通道的数据需要放置在连续的寄存器中
  • 通道的数据需要保序
  1. 贴图的维度过多:
  • 贴图数据同样需要连续跟保序

为啥这样的设定就会导致寄存器浪费?是有一些数据本来是不需要的吗?

Page 097

live寄存器数目是衡量分配overhead的重要指标,如何甄别哪些寄存器是live的?可以尝试AMD的Radeon GPU Analyzer工具,下面用一个案例来介绍。

Page 098

在这个案例中,没有overhead,所有的运算都是在原地(寄存器)中完成的

Page 099

但是,如果我们稍微修改下代码,如右下角所示,在这里就只需要5个VGPR,但是分配了6个(因为计算不能在原地进行,且V2后续就不再使用,但是又不能释放 ),导致了一个浪费。

Page 100

另外,如果使用半精度的浮点数,也有助于减少寄存器分配的浪费,比如min16float4分配的寄存器会少很多。

Page 101
Page 102

下面来看下水体方案实现过程中的一些问题,主要有上述图中所整理的几方面的内容:

  1. 两个depth buffer带来的大量的bug(什么时候该用哪个并不那么明确,加上链式的后续计算,stencil等,情况会变得非常复杂)
  2. 大量的小尺寸贴图,可以通过pack解决,同时使用pingpong机制实现复用降低内存消耗
  3. 屏幕空间曲面细分带来了一些问题
  • 需要大量的VS计算,与现代GPU中PS运算单元更多的设计相冲突。修改了数据的精度,降低到16位;后面准备将这个计算挪到Compute shader中,来避免这个问题
  • 边界情况导致的异常效果(见后面的图)
Page 103

如上图所示,当有两个相互连接的水体(河流+海洋)时,水体在远景处就会出现拉伸

Page 104

这里是修复后的效果。

Page 105

解决方案是,通过一个compute shader来检测两个水体相接的位置,之后调低这部分区域的displacement。

上图中右边的红色图是屏幕空间中的displacement贴图(FBM计算)

Page 106
Page 107

对这个图进行边缘检测,并做模糊

Page 108

Far Cry有个钓鱼小游戏,这个会有near-z跟far-z的问题。

如上图所示,钓线会频繁的改变颜色,在开启SSR的时候,会导致水体出现比较明显的闪烁效果。

Page 109

解决方案是在SSR的ray tracing做完之后,再增加一个后处理pass。

Page 110
Page 111
Page 112

这里提供了一些debug工具

Page 113

最终的性能数据,GPU总计耗时1.9ms,绿色是compute shader部分

Page 114

如果开启async compute,还能优化0.6ms

Page 115

这里给了个最终的视频。

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

推荐阅读更多精彩内容