关于URP中RendererFeature的使用及毛发效果的几种实现方式

目录

前言

一、铺垫

二、基于程序网格复制的实现

三、基于程序多材质的实现

四、基于RenderObjects手动配置的实现

4.1、多Pass实现多层毛发

4.2、override material实现多层毛发

五、基于RendererFeature代码编写的实现

5.1、创建RendererFeature模板

5.2、先熟悉一下各个方法

5.3、在RenderObjects源码中找它对材质做了什么

5.4、实现RenderObjects的参数面板

5.5、CommandBuffer实现多次绘制

六、资源链接


前言

——发这篇文主要还是想记录一下最近针对URP中RendererFeature的初步学习。因为网上搜的各种资料个人感觉对我这种水平的都太不友好了- -所以也算是写一篇自己看得懂的教程来给自己看,相信也适合刚玩U3D shader没多久的萌新_(:з)∠)_

——RendererFeature这玩意也算是个人小两年前刚接触shader时的一个历史遗留问题了,当时因为对U3D和程序的理解都不够所以搜了些资料也看不懂就搁置了···因为最近遇到一个需要做毛发效果的带蒙皮动画的模型,用之前那种网格复制的方式乍得一想解决不了动画的问题(其实传个权重就能简单解决- -···一会讲),所以下决心攻坚一波。

——经过国内外查了一大堆文章和视频资料,同时自己又去硬着头皮啃了啃U3D内置功能的源码试了又试,现在初级的RendererFeature的代码编写已经能实现了(放在最后讲)。但对其中很多像是CommandBuffer之类的功能模块的理解还很浅显,所以也希望专业的带佬能在评论区分享一下干货_(:з)∠)_

 ——然后说一下项目环境和版本。示例用的是unity 2020.3.36f1c1,URP 10.9(URP版本可在项目内上方window->package manage里看)。因为URP的频繁更新,所以一些内置功能和函数不太稳定,(比如renderfeature里要用到的ScriptableRenderPass类的OnCameraSetup方法在URP11里就变成了Configure)所以文章最后放的资源如果有报错可能是因为版本问题。

一、铺垫

——像上图这种密度较高但又需要做体积的效果,如果用的是U3D的内置管线而非URP,那么一种常见的套路是在SubShader里写多个pass。(shader没啥技术含量就不贴了- -网上类似的更好的一大堆,也可以直接到后面的资源里去找本文用的)

——每个pass的代码几乎完全相同,主要不同在于在顶点阶段需要根据当前层数来计算法线方向的顶点偏移量,这样便形成了同一个模型渲染了多层的效果。当层与层之间的间隔足够小时,离远了看上去就会像是一个有体积的突起(微积分的思想···另一种常见的描边效果也可以这么做,只不过只外扩了一层并剔除了正面)。但也因此,层数低或离得近时就会出现上图右侧明显的分层现象。

——问题在于,URP的shader本身不支持十几甚至几十个这么多pass的写法,即便写了实际也不会运行。如果像是描边这种两个pass就能搞定的效果还可以通过下面这样的方法来解决。

//第一个Pass
Pass
{
    Tags
    { "LightMode" = "SRPDefaultUnlit" }
    //其他代码
}

//第二个Pass
Pass
{
    Tags
    { "LightMode" = "UniversalForward" }
    //其他代码
}

//第三个Pass
Pass
{
    Tags
    { "LightMode" = "LightweightForward" }
    //其他代码
}

//SRPDefaultUnlit等是U3D内置的一些关键字。

——但草地和毛皮这种动不动十几二十层的就没辙了(因为内置关键字就三四个)。网上有些大佬说改管线源码自己加关键字可以让它识别,我只能说对于我这种水平,改管线什么的还是太远- -

——URP提供给我们实现多pass的方法就是RendererFeature(实际上它能做的远不止这个···)。

二、基于程序网格复制的实现

——本节的方法也是我以前不会用RendererFeature时做一些像是苔藓草坪之类的效果所使用的方案。

——简单来说,需要写一个C#脚本,在其中利用Mesh类提供的一些方法,基于原本的网格模型生成法向扩张的多层网格。其中像是网格膨胀的算法逻辑与shader中相同,关键代码如下。

float deltaThick = thick / (stepMax - 1);//单步厚度变化量
float deltaVCol = 1.0f / (stepMax - 1);//单步顶点色变化量,用来标识层数高低

//输出网格数据准备
Mesh mesh = new Mesh();
List<Vector3> verts = new List<Vector3>();
List<Color> colors = new List<Color>(); //顶点色记录层数归一化高度
List<Vector2> uvs = new List<Vector2>();
List<Vector3> normals = new List<Vector3>();
List<Vector4> tangents = new List<Vector4>();
List<int> indexs = new List<int>();
List<BoneWeight> weights = new List<BoneWeight>();

//输入的数据集
Vector3[] vertsIN = baseMesh.vertices;
Vector2[] uvsIN = baseMesh.uv;
Vector3[] normalsIN = baseMesh.normals;
Vector4[] tangentsIN = baseMesh.tangents;
int[] indexsIN = baseMesh.GetIndices(0);
BoneWeight[] weightsIN = baseMesh.boneWeights;

//遍历,顶点沿法线方向偏移
for (int stepIdx = 0; stepIdx < stepMax; stepIdx++)
{
    //数据
    for (int vIdx = 0; vIdx < baseMesh.vertexCount; vIdx++)
    {
        verts.Add(vertsIN[vIdx] + deltaThick * stepIdx * normalsIN[vIdx]);
        colors.Add(Color.white * deltaVCol * stepIdx);
        uvs.Add(uvsIN[vIdx]);
        normals.Add(normalsIN[vIdx]);
        tangents.Add(tangentsIN[vIdx]);
        if (weightsIN.Length > 0)
        {
            weights.Add(weightsIN[vIdx]);
        }
    }

    //顶点索引
    for (int i = 0; i < indexsIN.Length; i++)
    {
        indexs.Add(indexsIN[i] + baseMesh.vertexCount * stepIdx);
    }
}

//数据导入
mesh.SetVertices(verts);
mesh.SetColors(colors);
mesh.SetUVs(0, uvs);
mesh.SetNormals(normals);
mesh.SetTangents(tangents);
mesh.SetIndices(indexs, baseMesh.GetTopology(0), 0);
if (weightsIN.Length > 0)
{
    mesh.bounds = baseMesh.bounds;
    mesh.boneWeights = weights.ToArray();
    mesh.bindposes = baseMesh.bindposes;
}

——其中用顶点色记录了一下各层的高度,以便在shader中做AO,高度透明度阈值等相关计算。

——关于动画兼容的问题,其实只要加个骨骼权重数组weights,然后在结尾数据导入时把骨骼蒙皮相关的那些参数传递一下就可以保证复制出来的网格也是带蒙皮权重的了。

——但这方法有个问题。当原本的模型面数较高时,层数又设置的太高就会产生下面这样的崩坏。

 ——而低面数模型则无事发生。

 ——看上去感觉是顶点索引乱了,感觉算法逻辑也没问题- -又可能是和顶点权重有关?以前因为几乎都是在特效片上做的这个效果,所以没遇到过。如果有知道为啥的带佬希望能交流一下_(:з)∠)_

——下面是该方案的效果。

三、基于程序多材质的实现

——本节的方法是在之前研究RenderFeature时的副产物。以前没试过U3D里的多材质功能,也不知道它有啥样的效果。但在尝试下一节要讲的RenderObjects实现方法时,因为用到了复数个材质,我就联想到了Renderer组件自带的多材质功能。结果一试···

——好家伙这效果不也没啥区别?不用改网格,而且可控性还比RendererFeature强(Feature因其特性更适合做一些全局的效果),最重要的是这个层数多了也不会有上一节那个崩坏的bug,就是不知道性能方面跑这么多材质会咋样。电脑上看延迟毫秒数相较之前是没啥变化的- -

——实现思路就还是一个C#脚本,里面再现了shader里的所有参数方便动态生成材质及统一调整所有材质的参数,只不过法线膨胀的算法放到了shader里去做罢了,并且其膨胀偏移量单独公开出了一个参数让C#去传,而不是上一节那样用顶点色去记录。因为没啥技术含量就不贴代码了。

四、基于RenderObjects手动配置的实现

——终于到了需要用到feature的时候了。本节不涉及程序,先来说怎么通过手动配置的方式实现多pass。

——如果是新建的URP项目,在Project面板中应该能找到一个Settings文件夹,里面装了这么些东西。

——这些东西就是管线相关的资源配置文件。我们需要做的就是在ForwardRenderer中点Add Renderer Feature,然后选Render Objects。

——另外,我们也可以在Project面板右键->Create->Rendering->Universal Render Pipeline->Forward Renderer或PipeLine Asset(Forward Renderer),通过这种方式来创建新的管线配置文件。

——说几个本文中用到的关键的东西。

——Name没什么好说的,但建议最好改个新名字。这样在Project面板中,这个Feature才会以文件的形式显示出来,否则不会刷新显示,我们下一节要利用这个去看RenderObjects的源码。

——Event是这个feature对应的效果发生在渲染事件中的时机。

——Filters意为“过滤”。它的作用是,当场景中的某个物体对应的3个属性都包含在Filters下的Queue,LayerMask,LightMode Tags中时,这个feature对应的效果才会对这个物体生效,否则就“过滤”掉。

——其中,Queue是物体的渲染队列(即SubShader中Tags下的Queue,材质球最下面的Render Queue);

——LayerMask是物体所在层级;

——LightMode是【场景物体】使用的材质对应的shader中,【Pass】下的Tags中的LightMode。

——需要注意的是,如果feature的tags中什么都不写,则默认其中包含了第一节中那些内置LightMode关键字,而不是为空,其原因可在下一节的源码分析中了解。

4.1、多Pass实现多层毛发

——Pass下的Tags,或者说LightMode一般情况下是不用写的,它会自动使用第一节中说到的一些管线内置的关键字。如果擅自写一些自定义的LightMode,比如上面的TTT,反而会使模型无法正常渲染出来。

——但若是需要多Pass,自定义LightMode就是一条可选的道路。还是以上面的TTT为例,我们只需要在feature的tags列表中加入自己自定义的LightMode,并且保证前面的Queue和Layer配置正确,自定义LightMode的shader便可正常显示。

 ——更进一步,我们就可以模仿内置管线中的多Pass写法进行无脑的复制粘贴,只不过在那之后要给每个pass都加一个不同的LightMode,然后全都写进feature的tags列表里即可实现URP下的多pass。

4.2、override material实现多层毛发

——Filters下面还有个Overrides配置项,意为推翻、重写等。我们主要讲其中的Material。

——其作用效果是,当场景中的物体满足Filters中的条件时,若其LightMode是自定义的,则用Material中的材质效果替换这个物体原本的渲染效果;

——若其LightMode使用的是内置关键字,则在原有渲染效果基础上附加Material中的材质渲染效果。

——至于PassIndex,很容易就能猜到,是Material对应shader中所使用的是第几个pass。

——基于此,我们就又有一条做多层毛发效果的路可以选,即在override material对应的shader中写多pass,而非4.1中场景物体的shader。而这次甚至不需要自定义LightMode他也能识别(靠PassIndex识别)。但可惜的是,PassIndex留给我们的输入框只有一个。若是走这条路,我们需要多加几个RenderObjects,然后分别设置不同的PassIndex才行。又或者不愿意复制粘贴Pass的话,还可以不同的RenderObjects采用相同的PassIndex,但不同膨胀参数的材质球(这样一来就和第三节的多材质方法类似了)。

五、基于RendererFeature代码编写的实现

——总而言之,不论4.1还是4.2,不论手动复制多Pass或是手动挂载多个RenderObjects的方式,对于我这个喜欢“通解”的理科生来说实在显得不够优雅。关键就在于,这可控性太差了。第二三节的方法好歹能动态调整层数,但Feature这些是算个甚?于是乎,只好硬着头皮去试了试下面这个最终杀器。

——友情提示- -虽说前言说了本篇教程适合萌新,但也不是完全0基础对程序一点概念都没有的外行人就能啃得下来的。建议在继续往下看之前,确保自己对面向对象的构造函数、类继承、方法重载、override方法重写、virtual虚函数等概念有最为初步的了解。(不用了解很多,大概知道什么东西就行,有些东西我现在了解的也不是很多)。

——关于下面讲到的一些类和方法,其中大部分在官方文档是查不到的,因为官方专门搞了个URP文档。但说实话,这个东西并没有想象中好用,至少个人觉得没有原本的官方文档的排版清楚,写的也不是很全,所以我们还是需要啃一下源码。

——那么继续。

5.1、创建RendererFeature模板

——我们这节自己写一个RenderObjects,或者说RendererFeature,动态改层数传参什么的在程序里做。Project面板右键->Create->Rendering->Universal Render Pipeline->Renderer Feature。结果刚一创建出来···

——龟龟这都是个甚?巨长的类名和乌央乌央一大片英文注释往往让萌新望而却步,而且注释内容往往翻译之后也是些看不太懂的不讲人话的东西。但别慌,我们接下来一步一步把它拆掉,啃掉这块硬骨头。

5.2、先熟悉一下各个方法

——首先,开头会用到这些命名空间。

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;

——然后把注释全删了,发现这个模板程序的结构还是很简单的。虽然暂时不知道干啥用的,但就是一个继承了ScriptableRenderPass的类中类+两个方法。然后先把相关的类和变量名都改短点,让它看上去不那么吓人。

——只看方法名的话,大概也能猜到每个方法大致都干了些啥。

——Create在默认模板里写了个event的赋值,应该就是之前RenderObjects里的Event渲染事件,而这个方法大概是做了些FurPass类的创建与变量初始化的操作;

——AddRenderPasses就是把当前pass,或者说效果加到渲染队列里,看上去像是不怎么用编程的东西,放在那就行;

——FurPass里的两个带camera的方法大概和一些相机参数的设置和释放有关系。

——FurPass里的Execute不太好推断,英文丢百度翻译是执行的意思,可能是执行相关的渲染算法?

——先对这些类的继承关系和方法留个印象,接下来我们就回收第四节的伏笔,去看一下RenderObjects的源码。

5.3、在RenderObjects源码中找它对材质做了什么

——首先我们需要确立一个目标,我们扒RenderObjects的源码,最主要的目的是希望知道他对4.2的override material做了什么。因为我的打算是,在feature中通过代码实现材质膨胀偏移参数的循环赋值(即material.SetFloat方法),以此实现多层毛发渲染。明确目的后,我们继续。

——如果你在第四节中听话改了RenderObjects的名字,那么现在在Project面板中,应该能在ForwardRenderer下面找到它对应的下拉列表。

——点这里的Edit Script就可以打开RenderObjects的代码实现。当然你如果硬是叛逆,也可以在Package内搜索RenderObjects找到它的cs文件。

——然后,不要被上面那一坨东西吓到,稳住,先不用管他们,看下面。

——可以发现,虽然没找到那个继承了ScriptableRenderPass的类中类(即对应之前我们自己写的FurPass),但是却找到了Create和AddRenderPasses方法,并且看内容,Create里虽然看上去有点眼花但都是在对renderObjectsPass中的一些东西赋值;而AddRenderPasses和我们之前的推测一样,什么都没改。最重要的是,我们在Create方法中看到了这两行。

renderObjectsPass.overrideMaterial = settings.overrideMaterial;
renderObjectsPass.overrideMaterialPassIndex = settings.overrideMaterialPassIndex;

——这应该就是4.2中的Material和PassIndex。

——因为overrideMaterial 是renderObjectsPass里的东西,那么我们顺藤摸瓜,找到RenderObjectsPass类去看看他在里面对overrideMaterial做了什么。另外,如果你像我一样无法在VS或其他编程环境里定位到Package内的类的位置,可以使用上面在Project面板直接搜索的方式查找。

——还是先不要管上面那一堆东西。先看RenderObjectsPass的父类,ScriptableRenderPass,也就是说它就对应我们之前自定义的FurPass类中类,RenderObjects不过是把这两个类拆开写成了不同的cs文件罢了,然后Execute方法也如约而至,更惊喜的是它并没有用到那两个带Camera的方法,也就是说我们可以再删掉两个方法。

——然后我们展开Execute方法,孩子再一次被吓到。

——cmd是什么?windows命令行那个黑框框吗???using又为啥会在逻辑代码里出现?它不是开头引用库用的吗?而且这下面还跟了个大括号又是什么闻所未闻的写法?

——但是,别慌,牢记我们的目的,我们只需知道它对override material做了什么,其他的都不用管。也就是说,我们只需要把这段代码中和overrideMaterial看上去直接或间接相关的部分拆出来,先复制粘贴过去试试即可(有问题大不了再改嘛- -),即以下这些代码。(变量名做了简化修改)

public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
    //排序层配置
    SortingCriteria sortingCriteria = (renderQueueType == RenderQueueType.Transparent)
        ? SortingCriteria.CommonTransparent
        : renderingData.cameraData.defaultOpaqueSortFlags;

    //材质配置
    DrawingSettings drawingSettings = CreateDrawingSettings(tagIds, ref renderingData, sortingCriteria);
    drawingSettings.overrideMaterial = overrideMaterial;
    drawingSettings.overrideMaterialPassIndex = overrideMaterialPassIndex;

    //绘制
    CommandBuffer cmd = CommandBufferPool.Get();
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();
    context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref filteringSettings, ref renderStateBlock);
    context.ExecuteCommandBuffer(cmd);
    CommandBufferPool.Release(cmd);
}

——然后会发现有这么些报错。

——这些报错基本都是对应的变量我们没有在自己写的FurPass类里定义造成的。我们可以参考一下RenderObjectsPass的构造函数;

——可以发现,在处理shaderTags时,它在列表为空的情况下往里塞了那些默认的LightMode关键字,这便解释了第四节中的现象。

——把它按需进行删减,并把上面的报错中出现的成员变量也复制过去。

//成员变量
public FilteringSettings filteringSettings;
public RenderStateBlock renderStateBlock;
private RenderQueueType renderQueueType;
private List<ShaderTagId> tagIds;
private Material overrideMaterial;
private int overrideMaterialPassIndex;

//构造函数
public FurPass(RenderPassEvent renderPassEvent, RenderQueueType renderQueueType, int layerMask, string[] lightModeTags, Material overrideMaterial, int overrideMaterialPassIndex)
{
    this.renderPassEvent = renderPassEvent;
    this.renderQueueType = renderQueueType;
    this.overrideMaterial = overrideMaterial;
    this.overrideMaterialPassIndex = overrideMaterialPassIndex;

    //渲染队列过滤配置
    RenderQueueRange renderQueueRange = (renderQueueType == RenderQueueType.Transparent)
        ? RenderQueueRange.transparent
        : RenderQueueRange.opaque;
    filteringSettings = new FilteringSettings(renderQueueRange, layerMask);

    //Shader Tag过滤配置
    tagIds = new List<ShaderTagId>();
    if (lightModeTags != null && lightModeTags.Length > 0)
    {
        for (int i = 0; i < lightModeTags.Length; i++)
        {
            tagIds.Add(new ShaderTagId(lightModeTags[i]));
        }
    }
    else
    {
        //管线默认的Tag
        tagIds.Add(new ShaderTagId("SRPDefaultUnlit"));
        tagIds.Add(new ShaderTagId("UniversalForward"));
        tagIds.Add(new ShaderTagId("UniversalForwardOnly"));
        tagIds.Add(new ShaderTagId("LightweightForward"));
    }
}

——然后会发现,现在只剩下一个RenderQueueType的报错。

——这是个自定义枚举类型,实际上就是我们4.1中Filters下的Queue。RenderObjects对应的定义在其命名空间下的开头。

——不用管前面的MovedFrom什么的东西,我们再复制粘贴一下,丢到FurPass外面就行。

——到此为止,若是前面的流程都跟着走,剩下的报错应该只有Create方法中的furPass实例化的构造函数参数没补齐了。

5.4、实现RenderObjects的参数面板

——实际上,看变量名也能猜到,刚刚我们复制的那些成员变量,对应的都是第四节中RenderObjects的参数面板里那些东西。

——对于RendererFeature脚本,其实和我们平常写的那些继承MonoBehaviour的脚本一样,public的参数可以在Inspector面板上看到。那么我们只需要对照上面的相关变量,在FurFeature里再写一遍即可。除此之外,还有一些像是毛发层数这种我们自定义的参数,也要公开出来。

[Header("【渲染时机】")]
[Space(20)]
public RenderPassEvent passEvent = RenderPassEvent.AfterRenderingSkybox;//毛发渲染发生的时机

[Header("【过滤】")]
[Space(20)]
public RenderQueueType queue = RenderQueueType.Opaque;//场景物体的渲染队列在该范围内时渲染
public enum RenderQueueType
{
    Opaque,
    Transparent,
};
public LayerMask layerMask;//只对此层内的物体附加毛发渲染
public string[] lightModeTags;//只对Shader Tag在该列表中的材质渲染

[Header("【材质】")]
[Space(20)]
public Material furMat;
public int passId;//使用材质对应shader中的第passId个Pass进行渲染

[Header("【材质参数】")]
[Range(2, 100)] public int step = 10;//层数

——需要说明的是,因为Overrides里的Depth等参数我们没用到(对应成员变量中的renderStateBlock),所以这里没写,对应的,成员变量中的renderStateBlock也可以删掉了。至于Execute中用到renderStateBlock的DrawRenderers方法,它有个不需要renderStateBlock的重载所以不用担心。

——然后把这些公开变量在Create方法里传一下即可。(需要在成员变量和构造函数中添加层数变量step)

public override void Create()
{
    furPass = new FurPass(step, passEvent, queue, layerMask, lightModeTags, furMat, passId);
}

——到目前为止,若是上面全部正确,虽然多层渲染的逻辑还没写,但我们自己写的feature已经有和RenderObjects相同的效果了!

5.5、CommandBuffer实现多次绘制

——成功就在眼前。从上面我们可以知道,主要的渲染逻辑其实就是写在Execute方法里的,那剩下的就是在Execute方法里想办法实现材质的循环赋值和多次绘制了。为此,我们不得不先试着理解一下其中最关键的几行命令,即那几个cmd。

——首先,所幸CommandBuffer这个东西可以在官方文档查到,看解释大概是一个用于保存某种图形绘制命令的东西,看CommandBuffer的内置方法里也有一些像是SetGlobalFloat的shader属性配置方法。

——CommandBufferPool我并没有查到,看名字像是某种缓存池,而且看这个手动Get,Release的操作,可能类似于RenderTexture中的GetTemporary方法,是获取一个临时的CommandBuffer?

——然后ExecuteCommandBuffer也可以查到。看意思好像是把cmd存到context里等待执行?不管怎样,还是可以发现一些有用的写法上的建议。

——最后DrawRenderers同样,看方法名和解释应该实际的绘制就是靠这个了。

——总结一下大致流程。

1、在CommandBufferPool里拿一个cmd,在cmd里面配置一些相关的渲染属性

2、用ExecuteCommandBuffer把配置完属性的cmd存到预备列表里,并Clear掉cmd

3、用DrawRenderers绘制当前cmd

4、把临时cmd从CommandBufferPool里释放掉

CommandBuffer cmd = CommandBufferPool.Get();
//
//cmd相关配置
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref filteringSettings);
/
CommandBufferPool.Release(cmd);

——可以合理猜测,我们需要循环配置材质参数做膨胀偏移的地方,应该就是中间注释夹起来的那段代码。但我翻了翻CommandBuffer的方法库,里面好像只有SetGlobalXXX这种设置shader全局参数的方法。那我们将计就计,把膨胀偏移参数写成一个全局变量试试看。

CommandBuffer cmd = CommandBufferPool.Get();
for (int i = 0; i < step; i++)
{
    cmd.SetGlobalFloat("_Fur_Height01_GLB", i / (step - 1.0f));
    context.ExecuteCommandBuffer(cmd);
    cmd.Clear();
    context.DrawRenderers(renderingData.cullResults, ref drawingSettings, ref filteringSettings);
}
CommandBufferPool.Release(cmd);

——虽然程序上有很多不明所以的地方,但效果上看着是没啥问题。至于更进一步的理解啥的,以后用到的时候再慢慢消化吧_(:з」∠)_

六、资源链接

https://pan.baidu.com/s/1GgPgl_QNDzEjeD-GbL48tQ​pan.baidu.com/s/1GgPgl_QNDzEjeD-GbL48tQicon-default.png?t=M85Bhttps://link.zhihu.com/?target=https%3A//pan.baidu.com/s/1GgPgl_QNDzEjeD-GbL48tQ

——提取码: fgfr

猜你喜欢

转载自blog.csdn.net/weixin_44155671/article/details/127393776