Unity Shader:细分曲面

因毛发渲染需要用几何着色器生成外壳和鳍状体,但鳍状体的个数本质上还是取决于原始网格的数量,如果使用细分曲面的话可以很有效地控制毛发的密度。本节学习在Unity中实现细分曲面。

外壳着色器和域着色器

细分曲面可以将物体细分为很小的部分,可以为几何体增添很多细节。

创建细分曲面着色器

在Unity中,我们先创建一个自己的头文件:

#if !defined(TESSELLATION_INCLUDED)
#define TESSELLATION_INCLUDED
...
#endif

头文件的写法和普通的C++头文件写法类似,只不过所指定的类别是TESSELLATION_INCLUDED

然后我们随意创建一个shader,将头文件包含在内:

#pragma target 4.6
...
#include "MyTessellation.cginc"

target即编译器所支持的着色器模型版本,如果要使用细分曲面着色器的话,需要高于4.6,另外,几何着色器需要高于4.0。

外壳着色器

与几何着色器类似,细分曲面阶段灵活性很高,可以对三角形、四边形或等值线进行操作。我们需要告知其要在哪一个表面上操作,并且将必要的数据给予它,这是外壳着色器的任务,我们向头文件中添加函数:

void MyHullProgram(){}

因为该外壳着色器在一个表面片上执行操作,我们添加一个InputPatch操作:

void MyHullProgram(InputPatch patch){}

一个片式网格点的结合,类似于几何着色器的流参数,我们需要确定顶点数据的格式,这里使用VertexData结构体(这是自定义的顶点着色器输出数据结构体):

void MyHullProgram(InputPatch<VertexData> patch){}

又因为这里的每个片是一个三角形,所以包含3个顶点,所以需要标识出来:

void MyHullProgram(InputPatch<VertexData, 3> patch){}

外壳着色器的任务是将所需要的数据传入细分曲面阶段,尽管接受三个顶点,即一个完整的片,但还是应该每次只输出一个顶点。片的每个顶点会调用一次该函数,所以需要标识目前在控制哪一个顶点。这里使用无符号整型ID,且送往语义SV_OutputControlPointID:

void MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID){}

函数最简单的功能就是返回片的ID(这是默认什么都不做):

VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

注意,在shader文件中,我们也需要用预处理命令对外壳着色其进行标识:

#pragma hull MyHullProgram

不过就这么写的话会带来编译错误,因为和几何着色器一样,我们需要对函数配置属性。首先,我们要明确告诉函数,要操作三角形。这里使用UNITY_domain属性,tri作为参数:

[UNITY_domain("tri")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

我们还需要明确每个片输出三个控制点,每个三角形的点输出一个:

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

当GPU创建新三角形时,它需要知道要三角形的绘制顺序。像Unity中的其它三角形,它需要是顺时针的。我们使用UNITY_outputtopology()标识,参数需要为triangle_cw

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

GPU也需要知道如何切分片,使用UNITY_partitioning属性,这里我们使用integer模式:

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

除了分割方法,GPU还需要知道有多少片需要切割。这不是一个常量,可以每个片变化。我们可以使用UNITY_patchconstantfunc标识一个自定义函数。

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
VertexData MyHullProgram(InputPatch<VertexData, 3> patch, uint id : SV_OutputControlPointID)
{
    return patch[id];
}

片常量函数

一个片如何被细分的,这是一个片的属性。这意味着片常量函数每个片调用一次,不是每个控制点。该函数与MyHullProgram并行运行。

为了判断如何细分一个三角形,GPU使用细分系数。每个三角形片的边得到一个因数。三角形内部也有一个系数,三个边矢量和内部因数需要传递至相应的语义。结构体:

struct TessellationFactors
{
    float edge[3] : SV_TessFactor;
    float inside : SV_InsideTessFactor;
};

片常量函数将片作为一个输入参数,输出细分因数。我们这里将所有因数都设为1,相当于不细分:

TessellationFactors MyPatchConstantFunction(InputPatch<VertexData, 3> patch)
{
    TessellationFactors f;
    f.edge[0] = 1;
    f.edge[1] = 1;
    f.edge[2] = 1;
    f.inside = 1;
    return f;
}

域着色器

此时,着色器编译器会报错,说不能没有一个细分求值着色器来使用一个细分控制着色器。外壳着色器只是我们需要进行的细分工作的一部分。一旦细分曲面阶段决定了片如何细分,这就取决于几何着色器来求值结果,并生成最后三角形的顶点。因此我们创建一个域着色器的方法:

void MyDomainProgram(){}

外壳着色器和域着色器在相同的域中工作,即三角形。我们还是要通过UNITY_domain来标识:

[UNITY_domain("tri")]
void MyDomainProgram(){}

函数的参数,使用细分系数结构体,输出的片和之前类似:

[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch){}

细分曲面阶段决定片如何被细分,但并不生成新的顶点。取而代之,它为这些顶点生成一个重心坐标,域着色器根据这些数据生成最终的顶点。为了让这点变为可能,域着色器函数逐顶点调用一次,并为其提供重心坐标。使用SV_DomainLocation语义标识:

[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation){}

函数中,我们生成最终的顶点数据:

[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
    VertexData data;
}

为了找到顶点的位置,我们沿原始三角形的域插值,使用重心坐标。由于除了位置我们还要对其它的属性进行插值,这里我们定义一个宏定义:

[UNITY_domain("tri")]
void MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
    VertexData data;
    #define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) data.fieldName = \
        patch[0].fieldName * barycentricCoordinate.x + \
        patch[1].fieldName * barycentricCoordinate.y + \
        patch[2].fieldName * barycentricCoordinate.z;
    MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
    MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
    MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv1)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv2)
}

我们并没有对实例ID进行插值,因为Unity不支持同时进行实例化和细分曲面。为了避免编译错误,我们要移除实例化的预处理命令。

现在我们有了新顶点,它将被送往几何着色器或者下一阶段的插值。我们修改函数的返回结构体,并使用一个自定义的函数处理数据:

[UNITY_domain("tri")]
InterpolatorsVertex MyDomainProgram(TessellationFactors factors, OutputPatch<VertexData, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)
{
    VertexData data;
    #define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) data.fieldName = \
        patch[0].fieldName * barycentricCoordinate.x + \
        patch[1].fieldName * barycentricCoordinate.y + \
        patch[2].fieldName * barycentricCoordinate.z;
    MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
    MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
    MY_DOMAIN_PROGRAM_INTERPOLATE(tangent)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv1)
    MY_DOMAIN_PROGRAM_INTERPOLATE(uv2)

    return MyVertexProgram(data);
}

注意在shader文件中用于处理命令标识:

#pragma domain MyDomainProgram

控制点

MyVertexProgram只需要调用一次。不过我们仍需要定义在顶点着色器期间调用的顶点程序,位于外壳着色器前。这里我们可以在点上不做任何操作,所以我们可以只使用一个最简单的处理顶点数据的函数:

VertexData MyTessellationVertexProgram(VertexData v)
{
    return v;
}

在我们的三个pass的shader中标识该方法:

#pragma vertex MyTessellationVertexProgram

这会带来另一个编译错误,即位置语义的重复使用。为了解决这一问题,我们使用一个替代的输出结构体,对于顶点位置使用INTERNALTESSPOS语义。结构体剩下的部分和VertexData一样,只是没有实例ID。:

struct TessellationControlPoint
{
    float4 vertex : INTERNALTESSPOS;
    float3 normal : NORMAL;
    float4 tangent : TANGENT;
    float2 uv : TEXCOORD0;
    float2 uv1 : TEXCOORD1;
    float2 uv2 : TEXCOORD2;
};

然后改变函数的返回值:

TessellationControlPoint MyTessellationVertexProgram(VertexData v)
{
    TessellationControlPoint p;
    p.vertex = v.vertex;
    p.normal = v.normal;
    p.tangent = v.tangent;
    p.uv = v.uv;
    p.uv1 = v.uv1;
    p.uv2 = v.uv2;
    return p;
}

然后,MyHullProgram也需要改变参数中输入片的点的类型:

TessellationControlPoint MyHullProgram (
    InputPatch<TessellationControlPoint, 3> patch,
    uint id : SV_OutputControlPointID
) 
{
    return patch[id];
}

对于片常量函数也一样:

TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
    TessellationFactors f;
    f.edge[0] = 1;
    f.edge[1] = 1;
    f.edge[2] = 1;
    f.inside = 1;
    return f;
}

域着色器的参数类型也要改变:

InterpolatorsVertex MyDomainProgram(TessellationFactors factors, OutputPatch<TessellationControlPoint, 3> patch, float3 barycentricCoordinates : SV_DomainLocation)

至此,我们相当于编写了一个默认的细分曲面着色器。

细分三角形

在细分准备阶段我们可以细分片,我们可以使用一些更小的三角形集合来替代单个三角形。

细分系数

三角形片的细分由它的细分系数控制,我们在MPatchConstantFunction中决定这些系数。目前,我们将它们设为1,这没有任何效果。我们尝试修改系数:

TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
    TessellationFactors f;
    f.edge[0] = 2;
    f.edge[1] = 2;
    f.edge[2] = 2;
    f.inside = 2;
    return f;
}

然后就会发现三角形被细分了:



原始四边形:



系数设为2,三角形每个边被分为两个子边,每个三角形生成三个新的顶点。同样,每个三角形的中心也添加了一个顶点,这允许每个原始边上生成两个三角形,所以原始的三角形被6个更小的三角形替代。

如果系数全设为3:



每个边被分为3个子边,在这种情况下,没有一个中心顶点,三个顶点会添加到原始三角形中,组成一个更小的内部三角形,外部三角形会和内部三角形相连,组成连续三角形。

如果细分系数为偶数,那么就会有单个中心顶点,奇数的话,就会有一个中心三角形。

边与内部系数不同

边系数可以可以根据对应的边重写细分数量,只影响原始片边,不生成内部三角形。我们将内部系数设为7,边系数保持1,效果为:



可以发现,外围的三角形被剔除,感觉像是外部和内部的三角形被针线缝合在一起。

如果将边的系数设为7,内部系数设为1的话,结果为:



这种情况下,内部的因数的效果看起来就和2一样,否则新的三角形无法生成。

变化的因数

直接赋予细分因数不大常用,所以我们让其可配置,设定一个统一因数:

float _TessellationUniform;
...
TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
    TessellationFactors f;
    f.edge[0] = _TessellationUniform;
    f.edge[1] = _TessellationUniform;
    f.edge[2] = _TessellationUniform;
    f.inside = _TessellationUniform;
    return f;
}

然后在shader中声明这个属性

_TessellationUniform("Tessellation Uniform", Range(1,64))=1

小数化因数

即使我们使用float作为细分因数的类型,我们还是只能得到整数型的细分,这是因为我们使用的时整型的切分模式。我们可以改为fractional_odd:

[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("fractional_odd")]
[UNITY_patchconstantfunc("MyPatchConstantFunction")]
TessellationControlPoint MyHullProgram (
    InputPatch<TessellationControlPoint, 3> patch,
    uint id : SV_OutputControlPointID
) 
{
    return patch[id];
}
```.
也可以使用`fractional_even`模式,只不过基准是偶数。比如说`fractional_odd`模式就经常使用,因为它可以处理1这一因数,但`fractional_even`会强制设为2。

# 细分的启发
怎样才是最佳的细分因数呢?这没有绝对的答案,完全取决于实际。这里给出几个相关思路

#### 边因数
尽管细分因数需要逐边的提供,但不需要直接基数赋值。例如,我们可以逐顶点的决定因数,然后逐边的平均。也许因数存储在纹理中。在任何情况下,用一个独立的函数取决定因数是很方便的,通过给定一条边的两个控制点。
```c
float TessellationEdgeFactor(TessellationControlPoint cp0, TessellationControlPoint cp1)
{
    return _TessellationUniform;
}

该方法可以得到边的因数控制:

    f.edge[0] = TessellationEdgeFactor(patch[1], patch[2]);
    f.edge[1] = TessellationEdgeFactor(patch[2], patch[0]);
    f.edge[2] = TessellationEdgeFactor(patch[0], patch[1]);

对于内部因数,我们直接使用边的平均:

    f.inside = (f.edge[0]+f.edge[1]+f.edge[2])*(1/3.0);

边长度

边细分因数控制我们细分边的程度,那么因数如果基于这些边的长度那也是合情合理。例如,我们可以设定一个预期生成三角形边的长度,如果三角形边的长度大于该值,那么就应该通过预期的长度细分该三角形。添加一个变量:

float _TessellationEdgeLength;

然后在shader中添加一个属性:

_TessellationEdgeLength("Tessellation Edge Length", Range(0.1, 1)) = 0.5

我们需要一个着色器特性来在同一和基于边的细分中切换,使用:

#pragma shader_feature _TESSELLATION_EDGE

然后,在函数中进行相应的切换:

float TessellationEdgeFactor(TessellationControlPoint cp0, TessellationControlPoint cp1)
{
    #if defined(_TESSELLATION_EDGE)
        float3 p0 = mul(unity_ObjectToWorld, float4(cp0.vertex.xyz,1)).xyz;
        float3 p1 = mul(unity_ObjectToWorld, float4(cp1.vertex.xyz,1)).xyz;
        float edgeLength = distance(p0, p1);
        return edgeLength / _TessellationEdgeLength;
    #else
        return _TessellationUniform;
    #endif
}

屏幕空间的边长

我们可以根据物体在屏幕空间中的深度来调整细分的程度,因为远离摄像机的话,高精度细分是一种浪费。我们可以检测屏幕空间的边长来进行替代。首先调整属性的范围:

_TessellationEdgeLength("Tessellation Edge Length", Range(5, 100)) = 50

因为是像素的大小,所以设置大一些。

然后我们在方法中获得点在裁剪空间的位置,并计算距离(注意进行透视除法):

float TessellationEdgeFactor(TessellationControlPoint cp0, TessellationControlPoint cp1)
{
    #if defined(_TESSELLATION_EDGE)
        float4 p0 = UnityObjectToClipPos(cp0.vertex);

        float4 p1 = UnityObjectToClipPos(cp1.vertex);
        float edgeLength = distance(p0.xy/p0.w, p1.xy/p1.w);
        return edgeLength * _ScreenParam.y / _TessellationEdgeLength;
    #else
        return _TessellationUniform;
    #endif
}

使用观察距离

如果基于屏幕长度的话,那些在世界空间很长的边在屏幕空间就很很小,这样的话可能完全不会细分。我们可以回到使用世界空间边长度的方法,基于观察距离调整因数。

        float3 p0 = mul(unity_ObjectToWorld, float4(cp0.vertex.xyz,1)).xyz;
        float3 p1 = mul(unity_ObjectToWorld, float4(cp1.vertex.xyz,1)).xyz;
        float edgeLength = distance(p0, p1);

        float3 edgeCenter = (p0+p1)*0.5;
        float viewDistance = distance(edgeCenter, _WorldSpaceCameraPos);
        return edgeLength * _ScreenParams.y / (_TessellationEdgeLength * viewDistance);

使用正确的内部因数

尽管此时细分的结果看起来不错,但可能会因为内部细分的因数而变得很奇怪。比如下面这个例子,



在这个立方体例子中,两个相邻的三角形面的细分结果完全不同,这其实取决于三角形点的定义顺序。Unity默认的立方体不使用对称布局,而四边形使用,这也就意味着边的顺序会影响内部细分的因数。然而,我们只是获得了边因数的平均,所以不会影响顺序。

如果我们不调用函数,而是手动平均起来的话:

    f.inside =
        (TessellationEdgeFactor(patch[1], patch[2]) +
        TessellationEdgeFactor(patch[2], patch[0]) +
        TessellationEdgeFactor(patch[0], patch[1])) * (1 / 3.0);

结果就是这样:



可以发现完全不同了。问题出在哪呢?

注意,片常量函数和外壳着色器的剩余部分并行调用,但实际上更复杂。着色器编译器也可以并行计算边因数。在MyPatchConstantFunction中代码会被拆散并部分复制,使用一个并行计算三个边因数的进程替代,一旦三个进程完成,他们的结果会用来计算内部的因数。

无论编译器是否决定使用一个进程转换,它实际上都应该不影响最后的结果,而只影响性能才对。不幸的是,对于OpenGL核心生成代码时会有BUG,在计算内部因数时,不会使用三个边因数,而是只使用第三个边因数。

在片常量函数中,着色器编译器优先并行,它尽可能分割进程,在之后就不能优化复制的TessellationEdgeFactor的调用。我们以三个进程结束,每个进程计算两个点的世界位置、距离和最后的因数。然后,有一个进行计算内部的因数,也需要计算三个点的坐标,加上所有包含的距离和因数。当我们对内部因数做了所有的工作后,对每个边因数单独进行部分相同的工作就显得没那么有意义。

如果我们先计算点的世界坐标,接着对边和内部因数进行TessellationEdgeFactor函数的调用,那么着色器编译器就不会对每个边因数进行进程切换。我们用一个进程进行所有的计算。

float TessellationEdgeFactor(float3 p0, float3 p1)
{
    #if defined(_TESSELLATION_EDGE)
        float edgeLength = distance(p0, p1);

        float3 edgeCenter = (p0+p1)*0.5;
        float viewDistance = distance(edgeCenter, _WorldSpaceCameraPos);
        return edgeLength * _ScreenParams.y / (_TessellationEdgeLength * viewDistance);
    #else
        return _TessellationUniform;
    #endif
}

TessellationFactors MyPatchConstantFunction(InputPatch<TessellationControlPoint, 3> patch)
{
    float3 p0 = mul(unity_ObjectToWorld, float4(patch[0].vertex.xyz,1)).xyz;
    float3 p1 = mul(unity_ObjectToWorld, float4(patch[1].vertex.xyz,1)).xyz;
    float3 p2 = mul(unity_ObjectToWorld, float4(patch[2].vertex.xyz,1)).xyz;
    TessellationFactors f;
    f.edge[0] = TessellationEdgeFactor(p1, p2);
    f.edge[1] = TessellationEdgeFactor(p2, p0);
    f.edge[2] = TessellationEdgeFactor(p0, p1);
    f.inside = (TessellationEdgeFactor(p1, p2)+TessellationEdgeFactor(p2, p0)+TessellationEdgeFactor(p0, p1))*(1/3.0);
    return f;
}

感谢Jasper Flick

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