详解Unity官方插件: Animation-Instancing

前言

本篇博客的主旨并不是介绍如何使用 Animaiton-Instancing 插件 (对这部分内容有需求的同学可以很方便的参考网上其他同学的贡献),而是通过解构源码梳理原理的方式弄清楚这个插件到底能为我们带来什么,如何控制参数以便最大化运行时收益,以及还有哪些需要改进的地方。首先本文会从相对宏观的角度来解读该插件所解决的核心痛点,以及其之所以能够奏效的背后的核心原理。其次由于Animation-Instancing插件是按照面向数据和大规模运算而设计的,我重点拆解并讲解了其中的主要数据(包括运行时和预处理阶段生成的数据和结构)。在搞清楚运作原理和数据结构的前提下,我在文章的第三部分以表格形态简单罗列了一下该插件的API及重要全局变量,以作为对其他使用文档的补充。最后的部分是一些其他相对重要的分支,比如attachment,root motion等功能的支持,再比如对重要shader代码库函数的注释和说明等。作为附录,我也给出了一些自己在阅读源码过程中发现的bug(这些bug可能至今没有在官方原始工程repo中得到修正),希望对后来使用者有所帮助。

Big Picture

传统的骨骼动画是一项非常消耗CPU时钟的活儿,大体上它需要在每一帧的时间内,为场景中的每一个动画角色解算出最新的动作(Pose),每一次这样的动作计算又涉及到数以万计顶点位置的更新,更有甚者,这些更新本身需要数倍于顶点数目的矩阵运算和线性插值。虽然我们可以利用CullingGroup剔除不被拍摄到的角色,可以硬着头皮减少模型顶点数目,还可以利用引擎提供的部分基于CPU和(或)GPU的多线程处理能力并行的解算(矩阵运算)角色的动画数据,但是面对不断挤进屏幕的角色,这种主要依赖于昂贵CPU时间来处理海量矩阵运算的方法显然是不太明智的。Unity官方推出的 “Animation-Instancing” 插件给出了一套能够最大化利用GPU强大并行算力的方案(区别于之前引入的基于compute shader的GPU辅助矩阵计算)。接下来我们来简单梳理下这套方案的Big Picture:

动画解算最消耗资源的地方在于对模型网格顶点位置的计算,播放中的动画要求蒙皮上的顶点跟随关联的骨骼运动,而预先录制好的骨骼动画记录了骨骼在其父骨骼空间下的位置,由此可见,我们需要在每一个动画帧中读取骨骼根节点的信息,依次寻找到各级子节点,利用已知的 bindpose 矩阵(相当于记录了默认状态下蒙皮顶点绑定到当前骨骼的姿势),求解出目标顶点在其关联的骨骼空间下的坐标位置,最后一路使用骨骼父节点到子节点的逆变换矩阵,转换到真正的模型空间(根骨骼所处的空间)。这个过程看似复杂,但是只要我们愿意放弃骨骼之间的拓扑关系(本质上是放弃了动态操作骨骼链,支持实时物理反馈的可能性),那么只需要一次矩阵运算,就能让蒙皮顶点从默认模型空间位置变换到目标帧动画期望的模型空间位置!这个“关键矩阵”可以如此求解:

key_matrix[i] = root.worldToLocalMatrix * bonePose[i].localToWorldMatrix * bindPose[i];   // (1)
bindPose[i] = bonePose[i].worldToLocalMatrix * root.localToWorldMatrix;                   // (2)

变量名解释

  • 两式中所有的下标i均用于索引骨骼,
  • 式(1)的bonePose[i]特指骨骼[i]在当前动画帧时的transform,
  • 式(2)的bonePose[i]特指骨骼[i]在第0帧动画时的transform,
  • root是所有骨骼的根节点,也可以认为是模型对象的transform,它不随动画变化,
  • bindPose可以直接从Unity的Mesh对象中获取到,也可由式(2)手动计算出来。

矩阵乘法解释
将模型空间中点A转换到骨骼[i]表示的空间后,骨骼[i]受动画驱动产生了变化,用处于新位置的骨骼逆变换矩阵(从本地到世界),将点A变换到新的世界空间中,最后用模型根的变换矩阵,把点A从世界空间变回模型空间。可见经过这样一圈变化,我们的顶点从模型空间来,回到了模型空间去,期间受所绑定骨骼节点最新位置和朝向的影响,从模型空间来看产生了一点或多或少的变化,当然在骨骼空间观察,顶点的位置依然是保持不变的,既模型的顶点被骨骼"带着走"了。

至此Big Picture中最重要的一环已被我们斩获,可以设想我们能够在渲染管线的顶点阶段(vertex stage),通过采样预编码的纹理,获取到任意动画帧上任意骨骼的变换矩阵信息。那么只需要能准确的知道当前参与运算的模型顶点到底被那个(哪些)骨骼影响,影响权重是多少,还有我们的动画模型目前播放到哪一个动画帧,只需知道这几样信息,就能采样到符合预期的变换矩阵,计算并混合出这个顶点在目标动画帧时刻所处的模型空间新坐标了。这都不是什么难事,动画帧作为全模型统一的变量,可以直接通过材质传参实现赋值和更新,至于顶点与骨骼的绑定关系以及它们之间的影响权重等与顶点绑定的参数,自然可以预先埋设到模型Mesh的顶点色(Mesh.color)和顶点uv(Mesh.uv)中去。

剩下的拼图与上述核心渲染逻辑关系不大,主要是数据的收集、组织和派发在工程上的必要实现,这里面涉及的模块主要有:负责采样动画信息并烘焙成纹理的预处理模块;负责运行时解码数据,组织层级结构,统一维护和管理所有待渲染实例的中心化模块;还有负责追踪角色数据更新,处理动画事件,同步帧动画数据的实例模块。凡此种种,会在之后专门的单元中进一步介绍,不过作为Big Picture的一部分,它们的功能总的来说只是为了能在正确是的时候为shader提供正确的数据而已。

主要数据结构解析

这一部分主要围绕数据的编码和解码过程,以及数据的分层管理方案展开:

(一)编码流程与纹理中的数据结构

对于重数据处理的模块,没有什么比读图来得更加准确和高效了,所以具体细节请参考下图,这里文字部分只简单勾勒一下Codec的编码逻辑以及最终输出的数据结构:

(1)Animation-Instancing插件的编码模块(又叫预处理模块或Codec),它的工作对象是一个特定的prefab资源,会通过读取其上SkinnedMeshRenderer组件获取所有骨骼的关联信息,同时也会读取第一个找到的animator组件,而后递归访问其中所有动画状态(ChildAnimatorState)以获取动画剪影(AnimationClip)资源和动画事件信息(AnimationEvent),但是动画状态间的跳转逻辑(Transition)并不会被收集。而后Codec开启烘焙,对每个动画按照预设的FPS进行采样,计算并录入上文提及的顶点转换矩阵(每个矩阵与一个骨骼绑定),与此同时修改Mesh的顶点色和UV2,录入骨骼索引和权重(4通道,所以顶点最大支持被4块骨头影响)。

(2)编码后的数据以二进制byte[]形式紧密排列,详细信息参考下图,简单说一共可以拆分为三个部分,第一部分是角色自身各个动画的参数信息(AnimationInfo),这里面最重要的是动画Index到纹理Offset的映射关系。第二部分是可选的,用于存储各种待绑定的骨骼节点名称以及变换矩阵(矩阵并未使用),系统会在加载attachment时,利用编码的骨骼信息,确定变换矩阵,一次性将顶点从附加物模型空间变换到角色模型空间中去,并且位于绑定骨骼的正确的相对位置。最后一部分是序列化为byte[]的纹理单元,系统支持多纹理存放海量的动画帧数据,但是任何一个独立动画不能拆分到不同纹理中,而且除了最后一张纹理可以小于1024以外,其他纹理都必须是1024大小的(1024x1024是系统支持的最大纹理尺寸),以确保竟可能少用纹理。

Encoded Data Structure

(二)解码流程与数据结构

解码流程比较平淡,参考下图中左上角的流程图:每当挂在角色身上的 Animation-Instancing 被启动(Start),系统会自动收集其顶点和材质数据,整合到LodInfo结构中。随后角色脚本会找到全局唯一的Mgr,把自己注册上去。紧接着会立马发起数据加载request,到Unity工程的特定Path下寻找与自己的prefab同名的烘焙数据,加载之。如果一切顺利,那么系统会展开数据并分别存放到 InstanceAnimationInfo结构(对应编码纹理里的AnimationInfoAttachmentInfo)以及AnimationTexture结构中去,这些解码数据结构均由全局单例对象管理。

还是有一点可以提醒下大家,按照流程,如果我们需要展现1000个同源prefab单位的场景,那么就需要在场景中实例化创建1000个带有AnimationInstancing的该角色,这些对象带有完整的骨骼结构,挂载多个组件,以及处于失活状态的MeshRenderMonoBeahvior.Start方法执行期间会访问该组件及其中数据)。我想在运行时的内存消耗肯定会比完全由脚本模拟的方式来得多(主要由 <常驻的一堆无用骨骼节点> + <“可能的”对Mesh资源的访问而引起的数据加载&创建> 所致)。

Decode work flow

(三)数据的分层管理

这是一个比较大的逻辑范畴,笼统的说,系统从四面八方收集来数据,需要将它们分类、分层布置妥当,及时刷新,在每个渲染帧的最后,再以适当的方式提交给管线执行渲染。虽然我对系统使用的几个关键数据结构做了思维导图,但是很快法线如果我们不清楚影藏在背后的许多技巧性质的逻辑,数据结构中的大量变量和子结构根部无法通过名字准确理解含义。因此我决定从数据接受方的需求入参开始,反向展开数据,当然这里特指由Graphics提供的渲染接口:DrawMeshInstance

参考如下,标黑的数据结构体量由前往后 (既序号由小到大)依次膨胀(被包含),最早罗列出的则是直接输入到渲染API的结构参数。

(1)InstancingPackage.subMesh
说明:一个实例渲染的Mesh可能存在多个submesh,同时每个submesh也可以有其自身对应的材质Material,对DrawMeshInstance接口来说,每次调用传入的是当前Avatar对应Mesh的一个subMesh。
因此这一层结构的出现是为了 -> 区分不同submesh和material

(2)List<InstancingPackage>.at( packageIndex )
说明:单个DrawMeshInstance有最大合批上限,工程中设置为200,所有超过200个相同单位的渲染要求,会模式系统追加新的合批渲染(叫InstancePackage)。
因此这一层结构的出现是为了 -> 能够区分复数个InstancePackage

(3)List<InstancingPackage>[ boneTextureIndex ]
说明:模型动画可能被保存在不同的boneTexture纹理下,一个材质Mat只绑定了一张动画纹理,因而必须依据在播动画区分实例化合批,不同目标动画的材质不同,即便有一样的Mesh和MeshRender,也不能合批渲染。
因此这一层结构的出现是为了 -> 区分出拥有目标动画纹理资源的资源集合。
一点优化的迷思 -> 动画纹理以Textrue2DArray方式存放?

(4)VertexCache[ meshRenderIndex ]
说明:下标“meshRenderIndex”引导出的VertexCache对象主要用来对应一个特定的Renderer,具体而言是SkinnedMeshRender或MeshRender中的一个。
因此这一层结构的出现是为了 -> 确定待渲染的 Mesh 资源到底是那一个。

(5)LodInfo[ lod ]
说明:对应一个Avatar不同的Lod层级资源,依据远近不同,模型网格和渲染材质都会有不同的变化 。
因此这一层结构的出现是为了 -> 确立目标Lod级别的模型资源。

(6)Avatar_#N
说明:对应角色Avatar,AnimationInstance脚本,prefab等。这是数据管理层面的最上层。

然后再来看数据结构的全貌,应该会更加清晰,注意到上文中归纳的一线数据结构脉络能在下图中比较明确得定位到,唯一的例外是VertexChache,似乎 InstancingPackage 的管理者是 MaterialBlock

Data Overview

实际上如下图所示,VertexCache 结构主要存放着任何与顶点和材质有关的数据,比如模型的Mesh,还有与材质对应的MaterialBlock。且系统单例通过全局缓存池的方式管理新生成的VertexCache,这样当添加了同prefab的其他实例时,该对象不会被反复构造,而是访问同一个引用,从而也访问了相同的MaterialBlock结构。

VertexCache

参考如下MaterialBlock结构,系统通过引导相同prefab的对象访问并更新同一份数据,从而达到了归类可合批渲染对象,同时又区分不同实例之间独立的MaterialProperty。我们不妨考查一下 InstanceData 数据结构,系统在每次Render之前会更新其中全部数据,它所存放的数据数组的三个维度依次是:不同动画纹理(boneTexture) -> 不同合批次(200个单位合批一次) -> 同一批次下的不同实例。因此这个数据结构记录的正是不同实例之间的差异,主要涉及世界空间的变换矩阵,还有动画帧相关的几个参数。

MaterialBlock

API

AnimationInstancing -> API
AnimationInstancing -> Key Variant

其他技术细节

关于shader库的解读

参考如下源码,shader很简单,只有1~2个方法,使用时直接包含工程提供的AnimationInstancingBase.cginc库,然后在vertex stage中,使用模型空间位置之前,调用一下skinning方法,用返回值替换原有模型空间坐标即可。

half4 skinning(inout appdata_full v)
{
    //骨骼权重
    fixed4 w = v.color;
    //骨骼索引,这些骨骼会影响当前顶点 
    half4 bone = half4(v.texcoord2.x, v.texcoord2.y, v.texcoord2.z, v.texcoord2.w);

    //获取Instance数据
    float curFrame = UNITY_ACCESS_INSTANCED_PROP(frameIndex_arr, frameIndex); //当前动画帧,浮点数,可带小数点后尾数,用以表示前后动画帧之间的转换进度
    float preAniFrame = UNITY_ACCESS_INSTANCED_PROP(preFrameIndex_arr,  preFrameIndex);         //切换到当前动画前,上一段动画停留在的帧 
    float progress = UNITY_ACCESS_INSTANCED_PROP(transitionProgress_arr,  transitionProgress);  //前后两端动画的转换进度 

    //当前帧,整数,向下取整
    int preFrame = curFrame;
    //下一帧,整数
    int nextFrame = curFrame + 1.0f;

    //采样4次纹理,获取当前帧下,4个不同骨骼节点对自身的变换矩阵
    //注意一次纹理采样返回的half4只能覆盖4X4矩阵中的一行,因此需要至少3次采样 
    half4x4 localToWorldMatrixPre = loadMatFromTexture(preFrame, bone.x) * w.x;   
    localToWorldMatrixPre += loadMatFromTexture(preFrame, bone.y) * max(0, w.y);    //矩阵乘法分配律
    localToWorldMatrixPre += loadMatFromTexture(preFrame, bone.z) * max(0, w.z);    
    localToWorldMatrixPre += loadMatFromTexture(preFrame, bone.w) * max(0, w.w);

    //采样4次纹理,获取下一帧,4个不同骨骼节点对自身的变换矩阵
    half4x4 localToWorldMatrixNext = loadMatFromTexture(nextFrame, bone.x) * w.x;
    localToWorldMatrixNext += loadMatFromTexture(nextFrame, bone.y) * max(0, w.y);
    localToWorldMatrixNext += loadMatFromTexture(nextFrame, bone.z) * max(0, w.z);
    localToWorldMatrixNext += loadMatFromTexture(nextFrame, bone.w) * max(0, w.w);

    //计算当前帧位置(模型空间内)
    half4 localPosPre = mul(v.vertex, localToWorldMatrixPre);
    //计算下一帧位置(模型空间内)
    half4 localPosNext = mul(v.vertex, localToWorldMatrixNext);
    //按播放进度插值,获取准确的当前时间点的位置 
    half4 localPos = lerp(localPosPre, localPosNext, curFrame - preFrame);

    //求解Normal和Tangent在当前时刻的位置
    ...

    //采样获取上一段动画的顶点变换矩阵,为了性能考量,这里默认只受到一个骨骼影响,权重为100% 
    half4x4 localToWorldMatrixPreAni = loadMatFromTexture(preAniFrame, bone.x);
    half4 localPosPreAni = mul(v.vertex, localToWorldMatrixPreAni);

    //如果设置了前动画帧,那么就按照给定额progress比例,返回前后动画的插值,
    //如果没有设置前动画帧,直接返回前后两帧插值的结果
    localPos = lerp(localPos, localPosPreAni, (1.0f - progress) * (preAniFrame >  0.0f));

    //最后总结一下,一共花费了27次纹理采样 
    return localPos;
}

root motion

实现root motion需要通过挂载在角色身上的AnimationInstatnce.cs脚本在每个游戏帧中,使用烘焙好的线速度和角速度等参数,辅助计算出当前待播放的动画帧进度,进而推送到GPU端参与动画运算而得。因为不是重点,这里不做深入解读。

attachment

参考前文,特别是编码图示对attachment的描述,所谓的“挂载附属物”本质上是另一个独立的模型prefab,但是与主模型(带有骨骼)的某个骨骼挂点相互关联,播放时该模型融入粘在了角色模型的给的挂点上。为了达成这个效果,系统会在CPU运行加载attachment的阶段,将attachment上的某个挂点(比如说slot_A)与角色身上的某个同名骨骼挂点(slot_A)互相绑定和重叠起来。具体而言,我们视“挂载附属物”的默认模型空间等价于角色身上的(slot_A)骨骼空间,然后用骨骼空间bindPose矩阵的逆矩阵(inverse)将attachment从自己的模型空间变换到角色的模型空间,同时保留了与slot_A的相对位置关系。最后交给GPU渲染时,使用的是变换后的模型顶点,动画关联骨骼使用slot_A指定的骨骼,权重100%。

具体到工程应用中,我们要确保角色模型和attachment模型上都有同名的骨骼节点作为挂点,系统依赖一些相同的名字进行关联和绑定。

[后记和参考] 一些发现的坑

其中前四个不会造成计算错误,但是多少会有些性能影响;后三个在特定条件下会照成计算错误,直接影响表现效果。

  1. [创建但未使用] AnimationInstancingMgr.cs脚本中定义在同名class中的instanceDataPool变量 -> 有初始化,但是没有任何地方引用,具体可参考CreateVertexCache方法内创建InstanceData并入池的操作,但是搜索工程并没有法线对池中对象的取用操作。

  2. [不可进入分支] AnimationInstancingMgr.cs脚本中 SetupVertexCache方法内,当法线render.bones的数量与合并过后的全部骨骼数量不对等时,会进入分支创建boneIndex数组 (通过new int[render.bone.Length]创建),里面有如下代码分支永远无法进入:

if (boneIndex.Length == 0)
{
    boneIndex = null;
}
  1. [重复创建和覆盖] AnimationInstancingMgr.cs脚本中的SetupVertexCache方法主要是设置VertexCache结构对象中的骨骼权重和材质引用,但是方法末尾会遍历packageList,为买一张boneTexture创建一个 InstancingPackage结构。这个的问题在于该结构重复创建了,更早之前在调用SetupVertexCache方法的AddMeshVertex方法中,已经在所调用的CreateBlock方法内完成了目标InstancingPackage结构的创建。

  2. [退出未彻底清理] AnimationInstancingMgr.cs脚本中 vertexCachePool 队列在 RemoveInstance() 操作后并未去除掉历史数据,可能导致Render过程中执行更多次无意义的空循环。

  3. [错误估算了烘焙纹理的尺寸和张数] AnimationGenerator.cs脚本中的CalculateTextureSize方法内,当待处理动画数据无法完整打包进一张(或多张)“最大尺寸”纹理时,会尝试将余下数据打包进一张能完整容纳且尺寸最小的纹理,但该方法所揭示的算法逻辑实际上是在寻找能完整放入“下一段”动画的纹理,而不是完整放入余下全部动画的纹理。这会导致最后一张纹理尺寸偏小。

[The size and number of baked textures were incorrectly estimated]
The problem code locates right at method "CalculateTextureSize" in script file "AnimationGenerator.cs". According to the project context, the method is trying to pack all animation data as tight as possible, which means it will try packing all data into ONE(or more) texture with largest size(1024) at first, if still remains some, it'll find out the best suited texture (probably with size smaller then 1024) to fit all rest data. However, when dealing with those remaining data, what the code really does is to find out a texture with certain size that can just hold data from "One Animation Clip", not from "Rest All Clips" , which may lead to a releatively smaller size of estimation for the last texture.

https://github.com/Unity-Technologies/Animation-Instancing/issues/116

  1. [创建了错误尺寸的烘焙纹理] AnimationGenerator.cs脚本中的PrepareBoneTexture方法内,第二个for循环用于创建烘焙纹理,其中涉及判断纹理width的逻辑有错误,导致width变量在count > 1时,只能取到size=1024尺寸的纹理 -> 导致最后一张纹理的大小被放大 -> 也恰巧解决了(5)导致的问题。
for (int i = 0; i != count; ++i)
{   //如果有多张纹理,既count > 1,理论上最后一张纹理的size使用textureWidth标记的值(一般会小于1024),而前面所有纹理size=1024
    //参考如下逻辑,判断条件应当为 count > 1 && i < count -1 ? ...
    int width = count > 1 && i < count ? stardardTextureSize[stardardTextureSize.Length - 1] : textureWidth;
    bakedBoneTexture[i] = new Texture2D(width, width, format, false);
    bakedBoneTexture[i].filterMode = FilterMode.Point;
}

[Wrong size of baking texture was created]
The problem code locates right at method "PrepareBoneTexture" in script file "AnimationGenerator.cs". According to the code, one can not generate any texture with the size equal to variant "textureWidth" which describes the size of last texture (which is miss-estimated as well).

https://github.com/Unity-Technologies/Animation-Instancing/issues/117

  1. [切换动画的插值采样问题] 动画切换涉及到动作融合,需要采样上一段动画指定帧数的编码数据,在shader中重构出那个状态的顶点坐标,然后再拿目标状态的顶点坐标去做Lerp。问题在于作者使用的材质只支持一张boneTexture纹理,如果上一段动画并没有编码在当前纹理中,那么重构动画的工作将会基于完全错误的数据执行。

[Sampling problem for interpolating animations]
Switching between different anim-clips involves in animation interpolation, there will be sampling requests among different animation-info blocks during shader's vertex stage. By reconstructing pre-anim and cur-anim frame state and appling interpolation based on transition progress, we get animation transition properly. However this could be true only if the two anim-infos were packed within the same boneTexture, otherwise we lost our pre-anim info or even worse(sampling on a totally wrong area). To solve this issue, maybe you could bind a texture2DArray to the instancing material and introduce one more shader variable as the extra index of boneTexture :)

https://github.com/Unity-Technologies/Animation-Instancing/issues/118

  1. [烘焙attachment问题] 在AniamtionGenerator.cs脚本的BakeWithAnimator方法中,有如下处理附加物体骨骼和绑定的逻辑:
List<Transform> listExtra = new List<Transform>();
Transform[] trans = generatedFbx.GetComponentsInChildren<Transform>();          //附加物对象(如手持的剑)上的全部transform
Transform[] bakedTrans = generatedObject.GetComponentsInChildren<Transform>();  //角色身上的全部transform 
foreach (var obj in selectExtraBone)
{
    if (!obj.Value)
        continue;

    for (int i = 0; i != trans.Length; ++i)
    {
        Transform tran = trans[i] as Transform;
        if (tran.name == obj.Key)
        {
            bindPose.Add(tran.localToWorldMatrix);
            //以下为修改后代码
            for (int j = 0; j < bakedTrans.Length; ++j)
            {
                if (bakedTrans[j].name == obj.Key)
                {
                    listExtra.Add(bakedTrans[j]);
                    break;
                }
            }
            //以下为原始代码
            //listExtra.Add(bakedTrans[i]);
        }
    }
}

代码内存for循环的行为逻辑是由问题的,源码中bakedTrans队列与实际参与循环的trans队列长度和内容属性都不一致,直接套用trans队列关联的索引值“i”取用bakedTrans中的东西,在逻辑上找不到合理性。事实上按照上式中修改后的代码对attachment进行烘焙是成功的。

[Malfunction when baking prefab with attachment]
The problem code locates right at method "BakeWithAnimator" in script file "AnimationGenerator.cs". For more details plz refer to the following code(and comments).

https://github.com/Unity-Technologies/Animation-Instancing/issues/119

参考

TODO

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

推荐阅读更多精彩内容