[Unity]为了更好用的后处理——扩展URP后处理踩坑记录

扩展URP后处理踩坑记录

更新(2023.2.16)

已初步适配至Unity 2021 URP 12.1.x版本,仓库地址:pamisu-kit-unity,测试场景为Assets/Examples/CustomPostProcessing/Scenes/ 中的CustomPP3DCustomPP2D
依然是本篇文章中的实现思路,只是稍微修改了后处理效果渲染的相关RT。

自定义后处理效果-3D

自定义后处理效果-2D

由于时间有限,没有修改得很完善,也没有充分测试,只测试了打包PC端的情况,并且大部分后处理组件的插入点都在RenderPassEvent.AfterRenderingPostProcessing。如果有不正确的地方欢迎指出。


原文

在目前(10.2.2)版本,URP下的自定义后处理依然是通过Renderer Feature来实现,比起以前的PPSV2麻烦了不少,看着隔壁HDRP的提供的自定义后处理组件,孩子都快馋哭了。既然官方暂时没有提供,那么就自己先造一个解馋,对标HDRP的自定义后处理,目标效果是只需简单继承,就能添加自定义后处理组件。实现过程中遇到了不少问题,但对URP的源码有了初步的了解。

效果
自定义Volume组件

实(cai)现(keng)过程:

  • 封装自定义后处理组件基类,负责提供渲染方法、插入点设置等,并显示组件到Volume的Add Override菜单中。
  • 实现后处理Renderer Feature,获取所有自定义组件,根据它们的插入点分配到不同的Render Pass。
  • 实现后处理Render Pass,管理并调用自定义组件的渲染方法。
  • 适配2D场景下的自定义后处理。

类关系:

后处理组件基类

首先要确保自定义的后处理组件能显示在Volume的Add Override菜单中,阅读源码可知,让组件出现在这个菜单中并没有什么神奇之处,只需继承VolumeComponent类并且添加VolumeComponentMenu特性即可,而VolumeComponent本质上是一个ScriptableObject。

Volueme的Add Override菜单
Bloom.cs

那么就可以定义一个CustomVolumeComponent作为我们所有自定义后处理组件的基类:

CustomVolumeComponent.cs

public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
{
    ...
}

通常希望后处理在渲染过程中能有不同的插入点,这里先提供三个插入点,天空渲染之后、内置后处理之前、内置后处理之后:

/// 后处理插入位置
public enum CustomPostProcessInjectionPoint
{
    AfterOpaqueAndSky, BeforePostProcess, AfterPostProcess
}

在同一个插入点可能会存在多个后处理组件,所以还需要一个排序编号来确定谁先谁后:

public abstract class CustomVolumeComponent : VolumeComponent, IPostProcessComponent, IDisposable
{
    /// 在InjectionPoint中的渲染顺序
    public virtual int OrderInPass => 0;

    /// 插入位置
    public virtual CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterPostProcess;
}

然后定义一个初始化方法与渲染方法,渲染方法中,将CommandBuffer、RenderingData、渲染源与目标都传入:

/// 初始化,将在RenderPass加入队列时调用
public abstract void Setup();

/// 执行渲染
public abstract void Render(CommandBuffer cmd, refRenderingData renderingData, RenderTargetIdentifiersource, RenderTargetIdentifier destination);

#region IPostProcessComponent
/// 返回当前组件是否处于激活状态
public abstract bool IsActive();

public virtual bool IsTileCompatible() => false;
#endregion

最后是IDisposable接口的方法,由于渲染可能需要临时生成材质,在这里将它们释放:

#region IDisposable
public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

/// 释放资源
public virtual void Dispose(bool disposing) {}
#endregion

后处理组件基类就完成了,随便写个类继承一下它,Volume菜单中已经可以看到组件了:

TestVolumeComponent.cs

[VolumeComponentMenu("Custom Post-processing/Test Test Test!")]
public class TestVolumeComponent : CustomVolumeComponent
{

    public ClampedFloatParameter foo = new ClampedFloatParameter(.5f, 0, 1f);

    public override bool IsActive()
    {
    }

    public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RenderTargetIdentifier source, RenderTargetIdentifier destination)
    {
    }

    public override void Setup()
    {
    }
}
可以看到测试组件

Renderer Feature与Render Pass

好看吗?就让你们看看,不卖。URP并不会调用自定义组件的渲染方法(毕竟本来就没有),这部分需要自己实现,所以还是得祭出Renderer Feature。

官方示例中,一个Renderer Feature对应一个自定义后处理效果,各个后处理相互独立,好处是灵活自由易调整;坏处也在此,相互独立意味着每个效果都可能要开临时RT,耗费资源比双缓冲互换要多,并且Renderer Feature在Renderer Data下,相对于场景中的Volume来说在代码中调用起来反而没那么方便。

那么这里的思路便是将所有相同插入点的后处理组件放到同一个Render Pass下渲染,这样就可以做到双缓冲交换,又保持了Volume的优势。

获取自定义后处理组件

先来写Render Pass,在里面定义好刚才写的自定义组件列表、Profiler所需变量,还有渲染源、目标与可能会用到的临时RT:

CustomPostProcessRenderPass.cs

public class CustomPostProcessRenderPass : ScriptableRenderPass
{
    List<CustomVolumeComponent> volumeComponents;   // 所有自定义后处理组件
    List<int> activeComponents; // 当前可用的组件下标

    string profilerTag;
    List<ProfilingSampler> profilingSamplers; // 每个组件对应的ProfilingSampler

    RenderTargetHandle source;  // 当前源与目标
    RenderTargetHandle destination;
    RenderTargetHandle tempRT0; // 临时RT
    RenderTargetHandle tempRT1;

    /// <param name="profilerTag">Profiler标识</param>
    /// <param name="volumeComponents">属于该RendererPass的后处理组件</param>
    public CustomPostProcessRenderPass(string profilerTag, List<CustomVolumeComponent> volumeComponents)
    {
        this.profilerTag = profilerTag;
        this.volumeComponents = volumeComponents;
        activeComponents = new List<int>(volumeComponents.Count);
        profilingSamplers = volumeComponents.Select(c => new ProfilingSampler(c.ToString())).ToList();

        tempRT0.Init("_TemporaryRenderTexture0");
        tempRT1.Init("_TemporaryRenderTexture1");
    }

    ...
}

构造方法中接收这个Render Pass的Profiler标识与后处理组件列表,以每个组件的名称作为它们渲染时的Profiler标识。

Renderer Feature中,定义三个插入点对应的Render Pass,以及所有自定义组件列表,还有一个用于后处理之后的RenderTargetHandle,这个变量之后会介绍:

CustomPostProcessRendererFeature.cs

/// <summary>
/// 自定义后处理Renderer Feature
/// </summary>
public class CustomPostProcessRendererFeature : ScriptableRendererFeature
{
    // 不同插入点的render pass
    CustomPostProcessRenderPass afterOpaqueAndSky;
    CustomPostProcessRenderPass beforePostProcess;
    CustomPostProcessRenderPass afterPostProcess;

    // 所有自定义的VolumeComponent
    List<CustomVolumeComponent> components;

    // 用于after PostProcess的render target
    RenderTargetHandle afterPostProcessTexture;
    ...
}

那么要如何拿到所有自定义后处理组件,这些组件是一开始就存在,还是必须要从菜单中添加之后才存在?暂且蒙在鼓里。
通常可以通过VolumeManager.instance.stack.GetComponent方法来获取到VolumeComponent,那么去看看VolumeStack的源码:

VolumeStack.cs

它用一个字典存放了所有的VolumeComponent,并且在Reload方法中根据baseTypes参数创建了它们,遗憾的是这是个internal变量。再看VolumeMangager中,CreateStack方法与CheckStack方法对Reload方法进行了调用:

VolumeManager.cs

ReloadBaseTypes中对baseComponentTypes进行了赋值,可以发现它包含了所有VolumeComponent的非抽象子类类型:

VolumeManager.cs

看到这里可以得出结论,所有后处理组件的实例一开始便存在于默认的VolumeStack中,不管它们是否从菜单中添加。并且万幸的是,baseComponentTypes是一个public变量,这样就不需要通过粗暴手段来获取了。

接着编写CustomPostProcessRendererFeature的Create方法,在这里获取到所有的自定义后处理组件,并且将它们根据各自的插入点分类并排好序,放入到对应的Render Pass中:

CustomPostProcessRendererFeature.cs

// 初始化Feature资源,每当序列化发生时都会调用
public override void Create()
{
    // 从VolumeManager获取所有自定义的VolumeComponent
    var stack = VolumeManager.instance.stack;
    components = VolumeManager.instance.baseComponentTypes
        .Where(t => t.IsSubclassOf(typeof(CustomVolumeComponent)) && stack.GetComponent(t) != null)
        .Select(t => stack.GetComponent(t) as CustomVolumeComponent)
        .ToList();

    // 初始化不同插入点的render pass
    var afterOpaqueAndSkyComponents = components
        .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterOpaqueAndSky)
        .OrderBy(c => c.OrderInPass)
        .ToList();
    afterOpaqueAndSky = new CustomPostProcessRenderPass("Custom PostProcess after Opaque and Sky", afterOpaqueAndSkyComponents);
    afterOpaqueAndSky.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;

    var beforePostProcessComponents = components
        .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.BeforePostProcess)
        .OrderBy(c => c.OrderInPass)
        .ToList();
    beforePostProcess = new CustomPostProcessRenderPass("Custom PostProcess before PostProcess", beforePostProcessComponents);
    beforePostProcess.renderPassEvent = RenderPassEvent.BeforeRenderingPostProcessing;

    var afterPostProcessComponents = components
        .Where(c => c.InjectionPoint == CustomPostProcessInjectionPoint.AfterPostProcess)
        .OrderBy(c => c.OrderInPass)
        .ToList();
    afterPostProcess = new CustomPostProcessRenderPass("Custom PostProcess after PostProcess", afterPostProcessComponents);
    // 为了确保输入为_AfterPostProcessTexture,这里插入到AfterRendering而不是AfterRenderingPostProcessing
    afterPostProcess.renderPassEvent = RenderPassEvent.AfterRendering;

    // 初始化用于after PostProcess的render target
    afterPostProcessTexture.Init("_AfterPostProcessTexture");
}

依次设置每个Render Pass的renderPassEvent,对于AfterPostProcess插入点,renderPassEvent为AfterRendering而不是AfterRenderingPostProcessing,原因是如果插入到AfterRenderingPostProcessing,无法确保渲染输入源为_AfterPostProcessTexture,查看两种情况下的帧调试器:

插入到AfterRenderingPostProcess:

插入到AfterRenderingPostProcess

插入到AfterRendering:

插入到AfterRendering

对比二者,可以发现插入点之前的Render PostProcessing Effects的RenderTarget会不一样,并且在插入到AfterRendering的情况下,还会多出一个FinalBlit,而FinalBlit的输入源正是_AfterPostProcessTexture

FinalBlit

所以定义afterPostProcessTexture变量的目的便是为了能获取到_AfterPostProcessTexture,处理后再渲染到它。

现在已经拿到了所有自定义后处理组件,下一步就可以开始初始化它们了。在这之前,记得重写Dispose方法做好资源释放,避免临时创建的材质漏得到处都是:

CustomPostProcessRendererFeature.cs

protected override void Dispose(bool disposing)
{
    base.Dispose(disposing);
    if (disposing && components != null)
    {
        foreach(var item in components)
        {
            item.Dispose();
        }
    }
}

初始化

上面在CustomPostProcessRenderPass中定义了一个变量activeComponents来存储当前可用的的后处理组件,在Render Feature的AddRenderPasses中,需要先判断Render Pass中是否有组件处于激活状态,如果没有一个组件激活,那么就没必要添加这个Render Pass,这里调用先前在组件中定义好的Setup方法初始化,随后调用IsActive判断其是否处于激活状态:

CustomPostProcessRenderPass.cs

/// <summary>
/// 设置后处理组件
/// </summary>
/// <returns>是否存在有效组件</returns>
public bool SetupComponents()
{
    activeComponents.Clear();
    for (int i = 0; i < volumeComponents.Count; i++)
    {
        volumeComponents[i].Setup();
        if (volumeComponents[i].IsActive())
        {
            activeComponents.Add(i);
        }
    }
    return activeComponents.Count != 0;
}

当一个Render Pass中有处于激活状态的组件时,说明它行,很有精神,可以加入到队列中,那么需要设置它的渲染源与目标:

CustomPostProcessRenderPass.cs

/// <summary>
/// 设置渲染源和渲染目标
/// </summary>
public void Setup(RenderTargetHandle source, RenderTargetHandle destination)
{
    this.source = source;
    this.destination = destination;
}

之后在CustomPostProcessRendererFeature的AddRenderPasses方法中调用这两个方法,符合条件就将Render Pass添加:

CustomPostProcessRendererFeature.cs

// 你可以在这里将一个或多个render pass注入到renderer中。
// 当为每个摄影机设置一次渲染器时,将调用此方法。
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
    if (renderingData.cameraData.postProcessEnabled)
    {
        // 为每个render pass设置render target
        var source = new RenderTargetHandle(renderer.cameraColorTarget);
        if (afterOpaqueAndSky.SetupComponents())
        {
            afterOpaqueAndSky.Setup(source, source);
            renderer.EnqueuePass(afterOpaqueAndSky);
        }
        if (beforePostProcess.SetupComponents())
        {
            beforePostProcess.Setup(source, source);
            renderer.EnqueuePass(beforePostProcess);
        }
        if (afterPostProcess.SetupComponents())
        {
            // 如果下一个Pass是FinalBlit,则输入与输出均为_AfterPostProcessTexture
            source = renderingData.cameraData.resolveFinalTarget ? afterPostProcessTexture : source;
            afterPostProcess.Setup(source, source);
            renderer.EnqueuePass(afterPostProcess);
        }
    }
}

至此Renderer Feature类中的所有代码就写完了,接下来继续在Render Pass中实现渲染。

执行渲染

编写Render Pass中渲染执行的方法Execute

// 你可以在这里实现渲染逻辑。
// 使用<c>ScriptableRenderContext</c>来执行绘图命令或Command Buffer
// https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
// 你不需要手动调用ScriptableRenderContext.submit,渲染管线会在特定位置调用它。
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    var cmd = CommandBufferPool.Get(profilerTag);
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();

    // 获取Descriptor
    var descriptor = renderingData.cameraData.cameraTargetDescriptor;
    descriptor.msaaSamples = 1;
    descriptor.depthBufferBits = 0;

    // 初始化临时RT
    RenderTargetIdentifier buff0, buff1;
    bool rt1Used = false;
    cmd.GetTemporaryRT(tempRT0.id, descriptor);
    buff0 = tempRT0.id;
    // 如果destination没有初始化,则需要获取RT,主要是destinaton为_AfterPostProcessTexture的情况
    if (destination != RenderTargetHandle.CameraTarget && !destination.HasInternalRenderTargetId())
    {
        cmd.GetTemporaryRT(destination.id, descriptor);
    }

    // 执行每个组件的Render方法
    // 如果只有一个组件,则直接source -> buff0
    if (activeComponents.Count == 1)
    {
        int index = activeComponents[0];
        using (new ProfilingScope(cmd, profilingSamplers[index]))
        {
            volumeComponents[index].Render(cmd, ref renderingData, source.Identifier(), buff0);
        }
    }
    else
    {
        // 如果有多个组件,则在两个RT上左右横跳
        cmd.GetTemporaryRT(tempRT1.id, descriptor);
        buff1 = tempRT1.id;
        rt1Used = true;
        Blit(cmd, source.Identifier(), buff0);
        for (int i = 0; i < activeComponents.Count; i++)
        {
            int index = activeComponents[i];
            var component = volumeComponents[index];
            using (new ProfilingScope(cmd, profilingSamplers[index]))
            {
                component.Render(cmd, ref renderingData, buff0, buff1);
            }
            CoreUtils.Swap(ref buff0, ref buff1);
        }
    }

    // 最后blit到destination
    Blit(cmd, buff0, destination.Identifier());

    // 释放
    cmd.ReleaseTemporaryRT(tempRT0.id);
    if (rt1Used)
        cmd.ReleaseTemporaryRT(tempRT1.id);

    context.ExecuteCommandBuffer(cmd);
    CommandBufferPool.Release(cmd);
}

这里如果写得再简洁一些应该是可以只需要source和destination两个变量就行。需要注意某些情况下_AfterPostProcessTexture可能不存在,所以添加了手动获取RT的处理。如果不做这一步可能会出现Warning:

找不到_AfterPostProcessTexture

到这里Renderer Feature与Render Pass就全部编写完成,接下来使用一下看看实际效果。

使用一下看看实际效果

以官方示例中的卡通描边效果为例,先从把示例中的SobelFilter.shader窃过来,将Shader名称改为"Hidden/PostProcess/SobelFilter",然后编写后处理组件SobelFilter类:

SobelFilter.cs

[VolumeComponentMenu("Custom Post-processing/Sobel Filter")]
public class SobelFilter : CustomVolumeComponent
{
    public ClampedFloatParameter lineThickness = new ClampedFloatParameter(0f, .0005f, .0025f);
    public BoolParameter outLineOnly = new BoolParameter(false);
    public BoolParameter posterize = new BoolParameter(false);
    public IntParameter count = new IntParameter(6);

    Material material;
    const string shaderName = "Hidden/PostProcess/SobelFilter";

    public override CustomPostProcessInjectionPoint InjectionPoint => CustomPostProcessInjectionPoint.AfterOpaqueAndSky;

    public override void Setup()
    {
        if (material == null)
            material = CoreUtils.CreateEngineMaterial(shaderName);
    }

    public override bool IsActive() => material != null && lineThickness.value > 0f;

    public override void Render(CommandBuffer cmd, ref RenderingData renderingData, RenderTargetIdentifier source, RenderTargetIdentifier destination)
    {
        if (material == null)
            return;

        material.SetFloat("_Delta", lineThickness.value);
        material.SetInt("_PosterizationCount", count.value);
        if (outLineOnly.value)
            material.EnableKeyword("RAW_OUTLINE");
        else
            material.DisableKeyword("RAW_OUTLINE");
        if (posterize.value)
            material.EnableKeyword("POSTERIZE");
        else
            material.DisableKeyword("POSTERIZE");

        cmd.Blit(source, destination, material);
    }

    public override void Dispose(bool disposing)
    {
        base.Dispose(disposing);
        CoreUtils.Destroy(material);
    }
}

使用CoreUtils.CreateEngineMaterial来从Shader创建材质,在Dispose中销毁它。Render方法中的cmd.Blit之后可以考虑换成CoreUtils.DrawFullScreen画全屏三角形。

需要注意的是,IsActive方法最好要在组件无效时返回false,避免组件未激活时仍然执行了渲染,原因之前提到过,无论组件是否添加到Volume菜单中或是否勾选,VolumeManager总是会初始化所有的VolumeComponent。

CoreUtils.CreateEngineMaterial(shaderName)内部依然是调用Shader.Find方法来查找Shader:

CoreUtils.cs

添加Renderer Feature:

在Volume中添加并启用Sobel Filter:

效果:

继续加入更多后处理组件,这里使用连连看简单连了一个条纹故障和一个RGB分离故障,它们的插入点都是内置后处理之后:

条纹故障
RGB分离

效果:

效果

应用到2D

由于目前2D Renderer还不支持Renderer Feature,只好采取一个妥协的办法。首先新建一个Forward Renderer添加到Renderer List中:

场景中新建一个相机,Render Type改为Overlay,Renderer选择刚才创建的Forward Renderer,并开启Post Processing:

添加到主相机的Stack上,主相机关闭Post Processing:

URP 12.1.x中已经有了对2D的Renderer Feature支持,所以只需要添加自定义的Renderer Feature即可,其他使用方式和3D一致。

到这里对URP后处理的扩展就基本完成了,当然包括渲染在内还有很多地方可以继续完善,比如进一步优化双缓冲、全屏三角形、同一组件支持多个插入点等等。

对于编辑器中运行有效果,但打包后没有效果的情况,可能的原因是Shader文件在打包时被剔除了,这种情况只要确保Shader文件被包含或者可被加载即可(添加到Always Included Shaders、放到Resources、从AB加载等等)。

用到的素材:Free Space Runner Pack & Free Lunar Battle Pack by MattWalkden

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

推荐阅读更多精彩内容