unity生成预制体_[Universal RP]Unity通用渲染管线学习

c3cdad073e7af91b4112382f94165213.png

Unity正式加入了Universal RP(通用渲染管线),这里会记录一些官方文档,并分析管线的代码,文中使用Unity2019.3.0b1,Universal RP 7.0.1。

可编程渲染管线

为了解决仅有一个默认渲染管线,造成的可配置型、可发现性、灵活性等问题。Unity在管线设计的概念上做了转移,决定在C++端保留一个非常小的渲染内核,让C#端可以通过API暴露出更多的选择性,也就是说,Unity会提供一系列的C# API以及内置渲染管线的C#实现;这样一来,一方面可以保证C++端的代码都能严格通过各种白盒测试,另一方面C#端代码就可以在实际项目中调整,有任何问题也可以方便地进行调试。

6d831fb23c792a1e02b8c055421cf696.png

新的管线对用户而言主要是C# 端的API以及由这些API编写的一系列定制化的内置渲染管线。而在内部实现上,引擎C++端会负责多线程实现性能关键的部分,如上图所示,而C#端负责更高层的渲染指令调度。

4e172cced34c141a8b202b803aa82dae.png
可编程渲染管线的使用层设计

用户可以直接使用开源的内置管线,或者在内置管线的基础上进行修改,甚至直接编写定制化的管线。具体使用上渲染管线在工程中会生成特定的Asset,如下图所示,这个Asset序列化了这条管线的一些公共设置变量,并负责在运行时创建实际的渲染上下文;当这个Asset的设置变量在运行时发生变化,引擎会销毁当前上下文然后重新创建管线。

可编程渲染管线是URP的基础,通过它我们可以知道unity中怎么实现最基本的渲染,unity对渲染管线API封装程度。

详解可编程脚本渲染管线SRP - Unity Connect​connect.unity.com
81aaabb1da27a3f43e16195c54a7ddb1.png
Scriptable Render Pipeline Overview – Unity Blog​blogs.unity3d.com
2fa242fded597c122e10b8adc39c389c.png

通用渲染管线Universal RP

LWRP是URP之前的名称,两者基本没有区别,URP相对LWRP的变化主要是把PostProcessing集成到内部了。

Unity轻量级渲染管线LWRP源码及案例解析(上) - Unity Connect​connect.unity.com
cc60d180b9621f60489b15a685a3ac74.png
Unity轻量级渲染管线LWRP源码及案例解析(下)​connect.unity.com

Unity轻量级渲染管线LWRP源码及案例解析,讲解了URP的使用方法和拓展方法,里面有对SRP、URP的一些说明,URP与内置管线的对比,URP的源码结构。

在继续看源码细节之前,先看看URP的主要提升(相对于内置管线),和不完善的地方。

提升:

1 开源

可编程渲染管线最大的好处就是开源,对于有能力的团队,可以选择在SRP的基础上写自己的管线。而使用Unity提供的管线模板URP或HDRP,也可以把他们从Package包中提出到Asset中做更改,上面的文章有提到修改的注意事项(其实就是记着把Shader中的include路径,和代码中的Shader.Find之类的路径改一下),但是并不推荐这么做,因为版本更新的维护是噩梦。开源意味着容易定位到问题,对调试非常友好。

2 渲染路径改为单Pass Forward Rendering

内置管线的多Pass Forward Rendering,会在多光源时对额外的光源使用新的FowardAdd Pass计算,Pass数量是影响物体的光源数量,最大值为8。

URP的单Pass Forward Renderering,会将光源一次性传入Forward Pass,但由于单Pass能够传的数据有限,现在最多支持一个直线光外加4个其他光源。

这样做虽然并不完美,但多光源场景中DrawCall数量会大量下降。

3 拓展性(非代码修改)

URP在渲染队列中嵌入了拓展入口,相当于之前的CommandBuffer的可视化操作。上面的文章中有详细的使用方法。

用新的设计取代了GrabPass的结构,在Opaque渲染之后可以截出一张RenderTexture,提供给之后使用。

4 SRP Batcher

提供了一种新的批处理方式,基于Shader的批处理。不过这个技术还不是正式功能,有些局限,不支持Skinned Meshes、Material Property Blocks。

缺点:

1 不支持多相机叠加

这是很重要的功能,如果真用到了,就去改源码吧,要不等之后更新。

2 Defferred Renderring

现在URP里面没有延迟渲染,也没有时域抗锯齿TAA。

一个SRP代码示例

我们先看 详解可编程脚本渲染管线SRP - Unity Connect 中的最后一个例子,半透明渲染。

using 

渲染入口点为RenderPipeline类中的Render函数,参数为相机列表和渲染上下文,在这个函数的最后提交渲染上下文,完成渲染。填充渲染上下文的过程中,Unity封装好了一些方法,包括剔除操作、CommandBuffer的一系列指令、绘制渲染器等。

Universal RP

推荐先熟悉Universal RP的使用,再去看代码的实现,这样效率会更高。

Universal RP 7.0的文档​docs.unity3d.com

Universal RP的依赖Package有两个,CoreRPLibrary是URP和HDRP都用到的一些工具,ShaderGraph是shader的可视化节点编辑器,之前的PostProcessing已经集成到内部了。

e377fee54addca04d28a6800fdedc918.png

2c6ca929495b04ccbd4ccb2f1122a1c9.png

225988ed6f0d2c50002d38e3c041872d.png

Render方法

Universal RP的主要实现就在Universal RP的Runtime文件夹中,我们从渲染入口点开始看实现细节。

cd1747aeae35dacca154c32914e91fda.png
UniversalRenderPipeline类中的Render方法

0b98d500f7b3c7919f03d10394ef2ebd.png
一个Rendering Loop过程

整体结构非常简单,设置GraphicSetting参数,设置每帧Shader中的Global 变量,相机排序,相机遍历,每相机渲染。

首先看一下四个方法BeginCameraRendering、BeginFrameRendering、EndCameraRendering、EndFrameRendering,它们是基类RenderPipeline中的渲染管线回调。

6a1899d42e8ce837caa66f97af4bd699.png
RenderPipeline

GraphicsSettings.lightsUseLinearIntensity = (QualitySettings.activeColorSpace == ColorSpace.Linear);

更改颜色空间设置,这个设置位置在Player的OtherSetting中

b7eb19c9160ce0a8ae8d5d073c04b0ed.png

GraphicsSettings.useScriptableRenderPipelineBatching = asset.useSRPBatcher;

是否开启SRPBatcher(根据shader合并材质),这个功能使用还需要在Shader中将需要用CBuffer保存的变量用一段代码包含。并且有一定的限制。

dd3c8f8e900a94bd9998b6bacb0099a0.png
按材质合并与按Shader合并

ff10c222ada013b8b6cde25a7646c668.png
SRPBatcher使用注意事项

asset是指UniversalRenderPipelineAsset生成的管线资源,上面包含许多可调节设置,如SRPBatcher在倒数第四行,其他设置会在用到时说明。

9ef7be67f52f5f6917811b1146fd74da.png
UniversalRenderPipelineAsset中的设置

SetupPerFrameShaderConstants函数

ffd619b914e54f3f0eb76205cdd454f7.png

RenderSettings.ambientProbe是环境光的2阶球谐函数表达,Unity也使用SphericalHarmonicsL2存储LightProbe数据。

5b4724fdea797caa369550d1620b44d1.png
RenderSettings.ambientProbe在这里设置

将系数转到相应颜色空间,把参数-环境光颜色、Subtractive模式下的阴影颜色传递到Shader,这就是每帧的shader常量。

d78894ffad78bd3851c0dd29d8ddb63b.png
PerFrameBuffer

PerFrameBuffer类中的参数是每帧需要的数据缓存,这些静态数据在创建管线时(或修改管线参数时)被赋值。

c8e997d2b4e00bb9af34f81e94418ca2.png
UniversalRenderPipeline的构造函数

继续看Render函数中的SortCameras(cameras)

799394cff78f27565a2bb0df1c9153ea.png
按深度排序

接下来是遍历相机,我们先不关心VFX.VFXManager.ProcessCamera(camera)函数,它是为了在管线中集成Visual Effect Graph功能使用的,这里只有一个需要关心的方法RenderSingleCamera(renderContext, camera)。

RenderSingleCamera方法

public 

这个方法的过程如下:

  1. 初始化剔除参数
  2. 获取UniversalAdditionalCameraData
  3. 初始化CameraData
  4. 设置PerCameraBuffer(每相机使用的Shader Global变量PerCameraBuffer)
  5. 获取ScriptableRenderer
  6. 使用ScriptableRenderer继续填充剔除参数和CameraData
  7. 开始性能采样(Profiler面板)
  8. 编辑器模式下Scene相机额外显示UI
  9. 剔除
  10. 根据管线设置、CameraData、剔除结果,初始化渲染数据RenderingData
  11. 使用ScriptableRenderer根据RenderingData,Setup并Excute渲染上下文
  12. 结束性能分析
  13. 提交渲染上下文

1 初始化剔除参数

camera.TryGetCullingParameters(IsStereoEnabled(camera),outvar cullingParameters)用于获取剔除结果,IsStereoEnabled是判断是否是立体相机(VR/AR)。

6dfc818c14955f5ce1b71e2f828d7be2.png

2 获取UniversalAdditionalCameraData

获取摄像机的额外数据UniversalAdditionalCameraData,我还没搞懂为什么面板是空的,里面的数据能序列化,甚至还有Tooltip,估计是没开发完。

964b444faa7be96227dbba0768c071bb.png
挂着Camera所在GameObject上,面板默认不暴露

3 初始化CameraData,InitializeCameraData方法

static 

06a319a8dc76e92ca58cad1c8257b340.png
CameraData类

详细的设置大家阅读代码就可以得知。关于if(additionalCameraData !=null)那段设置,我没理解,现在的状态就是不添加UniversalAdditionalCameraData就会不支持后处理等效果,这里猜测这个设计的目的是为了区分两种相机模式,还希望大家能告诉我为什么。defaultOpaqueSortFlags的两种情况只差了一个是否按深度排序,其中camera.opaqueSortMode并没有被设置,在URP中始终未OpaqueSortMode.Default。captureActions只在Editor模式下生效,是录屏用的。cameraTargetDescriptor是RenderTextureDescriptor,在之后的Blit等操作里会用到,MSAA值也保存在这里。

4 设置PerCameraBuffer

82137309aa72aaa823b2e18897f579bf.png
SetupPerCameraShaderConstants方法

8435a13be60b025059c58c10dafab2d0.png
PerCameraBuffer类

这里设置的几个摄像机参数,_ScreenParams的4个向量格式在HLSL或者GLSL都会用类似的方法传递。

98d9ca8a30fcb6ba60aed4a5d84d2495.png
乘法顺序与命名顺序

读到这里,我对乘法顺序有疑问,于是去找shader中的乘法使用,以对比C#中的参数。

9f8f6e527daf4fb1536f9ae84321479c.png

7bc5ccbae7bff6fa25f498bf160feffa.png
com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl中的本地坐标转为世界坐标

首先注意shader中通常使用矩阵在左向量在右的常规乘法。

09d274329af807cdf7538130ddd09eaa.png
com.unity.render-pipelines.universalShaderLibraryInput.hlsl

注意红框中,UNITY_MATRIX_MV实际是UNITY_MATRIX_V × UNITY_MATRIX_M。矩阵乘法满足分配率,MVP矩阵乘向量p,与P(V(Mp))是相等的。这也是为什么C#端VP = P×V,只是命名习惯的问题。

5 获取ScriptableRenderer

ScriptableRenderer renderer = (additionalCameraData != null) ? additionalCameraData.scriptableRenderer : settings.scriptableRenderer;

ScriptableRenderer是抽象出的渲染功能实体,是UniversalRP可拓展的最外层,里面还有ScriptableRenderPass可以拓展。目前Unity提供了2个实现ForwardRenderer和Renderer2D,管线默认使用ForwardRenderer,默认资源是UniversalRP/Runtime/Data/ForwardRendererData.asset。可以每相机使用不同的ScriptableRenderer,这个设置方法和版本有关,可以看这个版本使用的addtionalCameraData.scriptableRenderer,有的版本使用camera.scriptableRenderer。

ac6b9cfa0c0553a00313e465b5c95ae9.png
使用Custom管线

6 使用ScriptableRenderer继续填充剔除参数和CameraData

c65aba89b1da629f18d43b928c766cb9.png
ScriptableRenderer.Clear

1d796517236798d5e16a5f4f05fb4f4e.png
ScriptableRenderer.SetupCullingParameters

e714cdc7402c8d7c504fc94d3b9024c9.png
ForwardRenderer.SetupCullingParameters

ef61e5c477691a721258d80e83b96351.png
Renderer2D.SetupCullingParameters

Clear方法相当于ScriptableRenderer的每相机初始化,SetupCullingParameters是虚方法,在ForwardRenderer和Renderer2D中有不同实现,后面以ForwardRenderer为主进行说明,ForwardRenderer中SetupCullingParameters进一步确定了阴影距离。

7 开始性能采样(Profiler面板)

CommandBufferPool是CommandBuffer对象池,ProfilingSample是封装的性能采样器,这两个类都在Core RP的Package中。

8 编辑器模式下Scene相机额外显示UI

9 剔除

var cullResults = context.Cull(ref cullingParameters),剔除是Unity封装好的一个方法,我们只能通过参数设置进行控制。

5451f61a15d602edd0791ac1bc6cd41b.png
剔除参数

ae4f5854c44677d506a47b661786e17f.png
剔除结果的内容

10 根据管线设置、CameraData、剔除结果,初始化渲染数据RenderingData

static 

RenderingData是用于存储每相机渲染所需的所有数据的结构体,这个方法的目的就是完全填充渲染数据,最后交给ScriptableRenderer使用。

ade2e4ac208d7826a33f5652fccce6ad.png
RenderingData结构体

var visibleLights = cullResults.visibleLights,从剔除结果中获得所有可见光GetMainLightIndex代码如下,可以从代码中看出MainLight的定义。

a4c213dabf7be4f20a8d5b73fef8c213.png
GetMainLightIndex方法

下面一段是MainLight和AdditionalLights是否存在阴影的判断,可以看出URP是不支持AdditionalLights的点光源和直线光源阴影的。CullResults和CameraData直接赋值给RenderingData。LightData、ShadowData和PostProcessingData方法由独立的方法赋值。

9e4d08aa265cf0733f9099bd5bc428d3.png
InitializeLightData方法

e64979c6f3ff4053dc45036036ae03c0.png
最大AdditionalLight数量是4,每相机最大可见光数量是16

689ac737ce5a25c0a1c22db671160873.png
LightData相关设置

1f81d95360523c4b2e7c4f63bdf65236.png
InitializeShadowData方法前半段

InitializeShadowData方法前半段,设置每光线的Bias值,这里UniversalAdditionalLightData和相机上挂的额外数据类似,不过这个脚本目前只有usePipelineSettings这一个字段,还是默认的true,面板同样是空的。

15c3942abe60fe4d9b90d0d5f335fd25.png
InitializeShadowData方法后半段

InitializeShadowData方法后半段,设置是否支持MainLight和AdditionalLights的阴影,用到了“MainLight和AdditionalLights是否存在阴影”数据,分别设置ShadowMap的尺寸,MainLight的阴影额外设置级联数据。设置是否支持软阴影、设置ShadowMap的DepthBuffer使用16bits数据。

e0b4d612e9d98d91c98a0e7c40bdf7e6.png
ShadowData相关设置

ba07293e0f1841b7649226fef9fec0ae.png
InitializePostProcessingData方法

2c6c8ff22c9780480d74aba7bfa1f444.png
PostProcessingData设置

supportsDynamicBatching:是否支持动态批处理。

00e83854976ca0e14eee44cec87d325c.png
PerObjectData枚举

PerObjectData是每Object需要的数据标记,通过下图方法获取。

7f6e769148c00b1b14a3a9cd3fb6c725.png

77b7bb2a60160a762938f19a1fcc9bfc.png

killAlphaInFinalBlit会在最后的Blit操作前开启ShaderKeyword:_KILL_ALPHA,把前面的数据完全覆盖。

202f77d0e326a56cf2f726b1a4ae4dd5.png
com.unity.render-pipelines.universalShadersUtilsBlit.shader的片元着色器

11 使用ScriptableRenderer根据RenderingData,Setup并Excute渲染上下文

在看具体实现之前,先看看ScriptableRender的设计。

ScriptableRender是由一个实体生成的——ScriptableRendererData。

c65077ebc4e1230dfb4c47e30e0cfb3e.png
ScriptableRendererData类

ScriptableRendererData中需要注意Create方法,它会在新建渲染管线时与管线一同创建,设置变化时重新创建ScriptableRender实例,还可以根据每相机设置来创建。ScriptableRendererData中的ScriptableRendererFeature集合,是URP提供给用户不用改代码就能增加Feature的功能,类似的,ScriptableRendererFeature继承自ScriptableObject,它可以创建出ScriptableRenderPass实例。

ScriptableRender类中定义了ScriptableRenderPass集合List<ScriptableRenderPass> m_ActiveRenderPassQueue = new List<ScriptableRenderPass>(32),ScriptableRenderPass中有抽象方法Execute,这个方法会在ScriptableRenderPass的不同子类中实现,context的渲染指令就是在ScriptableRenderPass.Execute方法中被填充的,比如context.DrawRenderers、context.DrawSkybox、context.ExecuteCommandBuffer等指令。ScriptableRenderer中的Setup方法会生成需要的m_ActiveRenderPassQueue,Excute方法会给m_ActiveRenderPassQueue排序分类、设置渲染目标、完成ScriptableRenderPass.Execute调用。

ce709f245f1de99d236b78d6dbffb278.png
ScriptableRenderPass.Execute

62fb992607ca8d25cdb7185323e26351.png
ScriptableRender.Setup

ScriptableRenderer.Setup是虚方法。

48744829ba10eefe911ad497772c02f6.png
ForwardRender类

ForwardRender类中定义了许多Pass结尾的变量,它们就是ScriptableRenderPass的子类。这里还有光照信息,光照在初始化和SetupLights方法中被赋值使用。

ForwardRender.Setup方法使用RenderData数据,来设置好渲染所需要的内容:管理并提交ScriptableRenderPass到m_ActiveRenderPassQueue;提前定义好这个相机要使用的颜色和深度目标,数据格式是RenderTargetIdentifier,是CommandBuffer中RenderTexture的标识;将RendererFeatures生成的ScriptableRenderPass添加到m_ActiveRenderPassQueue中;创建相机渲染目标的临时RenderTexture;设置Backbuffer的格式。

575700771d064d9f69433280adf2fe62.png
ForwardRender中初始化ScriptableRenderPass的过程

可以把渲染理解为绘制过程,这些ScriptableRenderPass子类就代表不同的过程,可以在上图中看到这些过程所处的阶段,即不同过程的绘制顺序。

  • VolumeBlendingPass:后处理使用的Volume融合,URP中作为第一个绘制使用
  • MainLightShadowCasterPass:主光源的ShadowCaster贴图绘制
  • AdditionalLightsShadowCasterPass:附加光源的ShadowCaster贴图绘制
  • DepthOnlyPass:绘制深度贴图
  • ScreenSpaceShadowResolvePass:使用ShadowCaster贴图和深度贴图绘制屏幕空间的阴影贴图
  • ColorGradingLutPass:绘制用于后处理ColorGrading的Lut贴图
  • DrawObjectsPass:绘制不透明物体或半透明物体,第一次使用处于RenderPassEvent.BeforeRenderingOpaques阶段,绘制不透明物体
  • CopyDepthPass:复制深度贴图
  • DrawSkyboxPass:绘制天空盒
  • CopyColorPass:复制颜色贴图
  • DrawObjectsPass:绘制不透明物体或半透明物体,第二次使用处于RenderPassEvent.BeforeRenderingTransparents阶段,绘制半透明物体
  • PostProcessPass:后处理,第一次使用处于RenderPassEvent.BeforeRenderingPostProcessing阶段阶段
  • PostProcessPass:后处理,第二次使用处于RenderPassEvent.AfterRenderingPostProcessing阶段阶段
  • CapturePass:用于RenderPassEvent.AfterRendering阶段的截屏,前面提到非Editor模式下renderingData.cameraData.captureActions是空的
  • FinalBlitPass:通常在没有后处理且不以相机为渲染目标的渲染中使用,比如RenderScale不为1时,用于把渲染结果重新传到屏幕

cf5c2dec45e2d204d7c23850fcfc85ac.png
ForwardRender.Setup第1段

fb9b75c77587917d6e6c3642b183f611.png
ForwardRender.Setup第2段

112476015ff57221a4355fce119a3f26.png
ForwardRender.Setup第3段

535db7d0e00355a229c1219a44baf2ca.png
ForwardRender.Setup第4段

因为ScriptableRenderPass的子类内容比较多,这里不展开分析Setup方法了,先去看看ScriptableRender.Execute的功能。

4bc49bf4dd402ff83b8293e4cab73e28.png
ScriptableRender.Execute

ScriptableRenderer.Execute是ScriptableRenderer层的绘制执行方法,过程如下:

  • 清理光照与阴影的ShaderKeyword
  • m_ActiveRenderPassQueue按照ScriptableRenderPass.renderPassEvent排序
  • 时间设置与缓存
  • 按Block划分m_ActiveRenderPassQueue
  • 执行RenderPassBlock.BeforeRendering的Block
  • 初始化相机相关的属性(Shader变量)
  • 设置光照(SetupLights方法)
  • 时间设置为之前缓存值
  • BeginXRRendering
  • 执行RenderPassBlock.MainRendering的Block
  • 绘制GizmoSubset.PreImageEffects的Gizmos
  • 执行RenderPassBlock.AfterRendering的Block
  • EndXRRendering
  • 绘制GizmoSubset.PostImageEffects的Gizmos
  • 清理数据

时间的设置,官方还会改动,代码中有注释说明。有个block的概念,m_ActiveRenderPassQueue被分为3个block,按照下图节点划分,FillBlockRanges为划分方法,ExecuteBlock为按Block执行。

9f22c3c366ea126c6c640b1a07c05405.png
Block划分依据

ExecuteBlock方法中进一步调用了指定Block中的ExecuteRenderPass方法,ExecuteRenderPass方法在确定ScriptableRenderPass对应的渲染目标后,调用ScriptableRenderPass.Execute设置每个渲染模块的上下文。

541399bc5da8644cfdeeccf707ea803f.png
ScriptableRenderer.ExecuteRenderPass

下面通过Unity中的FrameDebug看看渲染目标设置的过程,Game视窗当前分辨率为1600*900。

  1. SetRenderTarget.Clear1——VolumeBlendingPass被视为第一个以相机为渲染目标的Pass,设置了一个1600*900的<No name>
  2. SetRenderTarget.Clear2——MainLightShadowCasterPass在Configure中重新定义了渲染目标,渲染目标变更为2048*1024的TempBuffer
  3. RenderMainShadowMap——在TempBuffer上绘制ShadowCaster,这里的两个绘制对应两个阴影级联
  4. SetRenderTarget.Clear——DepthOnlyPass在Configure中重新定义了渲染目标,渲染目标变更为1600*900的_CameraDepthTexture
  5. DepthPrePass——在_CameraDepthTexture中绘制深度信息
  6. SetRenderTarget.Clear——ScreenSpaceShadowResolvePass在Configure中重新定义了渲染目标,渲染目标变更为1600*900的_ScreenSpaceShadowMapTexture
  7. ResolveShadows——根据_CameraDepthTexture、TempBuffer在_ScreenSpaceShadowMapTexture中绘制阴影
  8. RenderOpaques——在<No name>上绘制不透明物体,使用到了_ScreenSpaceShadowMapTexture,这里出现了渲染目标变更,但变更目标是之前存在的,所以并没有创建渲染目标
  9. RenderSkybox——在<No name>上绘制天空盒

18c2d58f6b88729aa29250e06421c15f.png
ForwardRender的一帧渲染

ExecuteRenderPass方法中有一行ClearFlag clearFlag = GetCameraClearFlag(camera.clearFlags),这个方法获取第一个Clear的ClearFlag,在不同平台上结果有差异,大家一定要注意。

12 结束性能分析

在ProfilingSample执行Dispose时cmd设置为结束性能分析,并立刻被提交到context中。

13 提交渲染上下文

context.Submit();


由于ScriptableRenderPass内容比较多,就不详细列出RenderData设置的参数是怎么作用于不同的ScriptableRenderPass了,可以在使用相应模块时再去了解。

之后会更新Universal RP中的Lit.shader的内容,这个Shader相当于默认渲染管线中的Standard.shader。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值