基于Unity的软光栅实现(3):基于Job system的多核加速光栅化

系列文章导航

本系列文章是关于本人的开源项目 URasterizer: A software rasterizer on top of Unity, accelerated by Job system & Compute Shader的总结和介绍,一共四篇。
第一篇:基于Unity的软光栅实现(1):框架搭建和矩阵构造
第二篇:基于Unity的软光栅实现(2):CPU单线程软光栅
第三篇:基于Unity的软光栅实现(3):基于Job system的多核加速光栅化 (本篇)
第四篇:基于Unity的软光栅实现(4):GPU Driven的光栅化流水线

拥抱CPU多核计算

在上一篇中,我们实现了CPU上的单线程软光栅,不出所料的是FPS很低。其实这已经是经过了一些优化,比如消除GC Alloc,使用快速的数组填充方法等。可惜代码本身的优化余地并不大,且被托管代码的性能所拖累。而现代CPU的发展方向早已经不是拼核心频率,多核计算已经不可忽视了。对于游戏引擎来说,经过10多年的发展,已经从单线程,到主线程+渲染线程+多线程加载,进化到主线程+渲染线程+多个工作线程并行完成任务。Unity也从2018.1开始支持Job System,作为其DOTS技术栈的基石。

Job System简介

使用Job System可以进行多线程编程,但Job System并不是简单的多线程。Job System通过创建Job来管理多线程代码,而不是直接使用线程。我们先看看直接使用线程有什么问题吧。当你写多线程程序时,往往会需要很多线程。尽管可以使用线程池优化线程的创建和回收,但仍然有多个线程处于激活状态。由于CPU核心少而线程多,线程之间会相互竞争核心资源。这会导致多个线程来回切换会产生开销。而Job System优化了这一点,Job System内部为每个CPU的逻辑核心创建一个工作线程,然后将不同的Job分配给这些工作线程去运行。Job被组织成队列,工作线程从队列中取出Job并执行,同时Job System可以管理Job之间的依赖,按照依赖顺序来执行不同的Job。每个核心一个工作线程一直进行,就不再需要来回切换线程(当然其他进程还是会占用核心),Job虽然多,但是大家都是排队执行的,没有切换线程的开销了。通过Job System,我们可以即方便又高效的写多线程代码。
多线程的另一个问题是Race Conditions,不同线程同时读写同一个变量,会造成非常隐晦的bug。Job System为了避免Race Conditions,做了两件事。首先,Job 中只能使用 blittable 数据类型。所谓blittable类型是指在托管代码中和非托管代码互相传递时,不需要转换的类型,具体可参考微软.net文档。Unity会向每个job传送数据的拷贝,这样这些数据就不会被多个job所影响。但是,我们往往需要Job操作大量数据,总不能全部copy吧,因此Unity提供了NativeContainer,顾名思义,这其实是将数据直接保存在Native内存中,可以直接在Job中访问而不需要copy进来。Job System的Safety system会跟踪对NativeContainer的读和写,因此可以检查出越界,内存泄漏以及race condition。NativeContainer即可以作为输入也可以作为输出,我们使用了其中一种类型:NativeArray。

ParallelFor Job

本文并不是Job System的教程(其实看官方文档足矣),只简要说一下URasterizer的JobRasterizer使用到的Job类型吧。很显然,我们有大量的顶点和三角形要处理,所以我们希望能并行计算它们,而Job System提供的 IJobParallelFor 接口,就可以很方便的并行计算很多数据:

	public interface IJobParallelFor
    {
        //
        // 摘要:
        //     Implement this method to perform work against a specific iteration index.
        //
        // 参数:
        //   index:
        //     The index of the Parallel for loop at which to perform work.
        void Execute(int index);
    }

实现这个接口的Job被核心执行时,会在Execture方法中传入当前计算的数据索引,通过这个索引找到当前需要计算的数据进行计算。而索引的范围,由调度该Job时指定的数组的长度来决定,比如以顶点数作为数组长度去调度Job,那么每个Execute中得到的索引就是[0,VertexCount-1]。调度Job时具体指定什么样的数组长度,由具体的需求决定,需要注意的是,填入多大的长度数,就会有多少次Job被执行,这其实和数组没有什么关系,比如你输入的数据并不是数组,但是你就想重复执行Job N次,那么你就填N就行了,然后Exectue被调用时,你会得到[0,N-1]范围之内的某一个索引。当然了,我们正常的使用,是会输入一个或多个数组,然后调度时指定数组长度,Exectue中使用索引值来索引数组数据,URasterizer的JobRasterizer就是这么做的。

JobRasterizer

URasterizer项目中,基于Job system的光栅化渲染器类是JobRasterizer。它同样实现了IRasterizer接口,所以基本框架和CPURasterizer一样,只是具体计算的过程放到了Job中。且由于使用了Job,需要的数据也不太一样。这篇文章可以看做入门Job System的一个例子,其中也会总结遇到的一些问题和解决方案。主要参考了Unity的文档,以及Unity在GDC上的一个演讲。
下图是JobRasterizer在Unity Profiler中的表现,可以看到很多并行的Job在执行:
在这里插入图片描述

数据准备:JobRenderObjectData

	public class JobRenderObjectData : IRenderObjectData
    {        
        public NativeArray<Vector3> positionData;        
        public NativeArray<Vector3> normalData;        
        public NativeArray<Vector2> uvData;
        public NativeArray<Vector3Int> trianglesData;             
        

        public JobRenderObjectData(Mesh mesh)
        {            
            positionData = new NativeArray<Vector3>(mesh.vertexCount, Allocator.Persistent);            
            positionData.CopyFrom(mesh.vertices);

            normalData = new NativeArray<Vector3>(mesh.vertexCount, Allocator.Persistent);   
            normalData.CopyFrom(mesh.normals);         
            
            uvData = new NativeArray<Vector2>(mesh.vertexCount, Allocator.Persistent);
            uvData.CopyFrom(mesh.uv);

            //初始化三角形数组,每个三角形包含3个索引值
            //注意这儿对调了v0和v1的索引,因为原来的 0,1,2是顺时针的,对调后是 1,0,2是逆时针的
            //Unity Quard模型的两个三角形索引分别是 0,3,1,3,0,2 转换后为 3,0,1,0,3,2
            var mesh_triangles = mesh.triangles;
            int triCnt = mesh_triangles.Length/3;
            trianglesData = new NativeArray<Vector3Int>(triCnt, Allocator.Persistent);
            for(int i=0; i < triCnt; ++i){
                int j = i * 3;
                trianglesData[i] = new Vector3Int(mesh_triangles[j+1], mesh_triangles[j], mesh_triangles[j+2]);
            }                      
        }

        public void Release()
        {
            positionData.Dispose();  
            normalData.Dispose();
            uvData.Dispose();
            trianglesData.Dispose();          
        }

    }

渲染数据方面,不能直接使用顶点属性的数组了,如上所述,Job中需要使用NativeContainer。这里使用了4个NativeArray,分别是顶点坐标数据,顶点法线数据,顶点uv数据和三角形索引数据。注意我们对于每个需要渲染的Mesh,其NativeArray数据需要在程序运行期间一直存在,因此NativeArray的Allocatro Type选择 Allocator.Persistent。这样我们必须在程序退出时调用NativeArray的Dispose()方法。否则Safety System就会检测到内存泄漏。NativeArray的初始化,还需要指定数据元素的类型和数量。对于顶点坐标法线和UV,使用Vector3或Vector2即可,数量自然是mesh的vertexCount。而对于三角形索引,我们使用Vector3Int类型,数量为三角形数量,即索引数除以3。另外,正如我们在上篇所说,需要调整三角形环绕方向,这里由于我们要准备三角形数据,因此就同时进行了调整。向NativeArray中传入数据,如果已经有现成的c#数据数组,可以使用CopyFrom方法,比如这儿的坐标、法线和UV数组。否则就必须在循环中设置数组元素值了,比如这儿的trianglesData。

缓冲区表示和Clear

由于我们使用Job进行计算,且在Job中写入缓冲区,因此缓冲区也要使用NativeArray:

		NativeArray<Color> _frameBuffer;        
        NativeArray<float> _depthBuffer;

初始化如下:

			int bufSize = w * h;          
            _frameBuffer = new NativeArray<Color>(bufSize, Allocator.Persistent);
            _depthBuffer = new NativeArray<float>(bufSize, Allocator.Persistent);

然后对于缓冲区的清除,使用了清除一个临时数组,然后使用NativeArray的CopyFrom方法一次性copy:

		public void Clear(BufferMask mask)
        {
            ProfileManager.BeginSample("JobRasterizer.Clear");


            if ((mask & BufferMask.Color) == BufferMask.Color)
            {             
                URUtils.FillArray<Color>(temp_buf, _config.ClearColor);
                _frameBuffer.CopyFrom(temp_buf);
            }
                      
            if((mask & BufferMask.Depth) == BufferMask.Depth)
            {                
                _depthBuffer.CopyFrom(temp_depth_buf);
            }                      
                       

            _trianglesAll = _trianglesRendered = 0;
            _verticesAll = 0;

            ProfileManager.EndSample();
            
        } 

这里没有尝试使用若干Job去同时清除缓冲区,毕竟CPU核心数量没那么多。

渲染流程

Job规划

我们使用两个Job类去进行整个渲染:顶点Job和三角形Job。即先变换顶点,然后光栅化和渲染三角形。为啥不直接处理三角形呢?因为三角形会共享顶点,直接处理三角形就会造成一些顶点被重复处理。虽然我们目前的顶点操作比较简单,开销不大,但如果顶点操作很复杂,就会浪费性能。

顶点Job调度

		NativeArray<VSOutBuf> vsOutResult = new NativeArray<VSOutBuf>(mesh.vertexCount, Allocator.TempJob);
		
		VertexShadingJob vsJob = new VertexShadingJob();            
		vsJob.positionData = ro.jobData.positionData;
		vsJob.normalData = ro.jobData.normalData;
		vsJob.mvpMat = mvp;
		vsJob.modelMat = _matModel;
		vsJob.normalMat = normalMat;
		vsJob.result = vsOutResult;
		JobHandle vsHandle = vsJob.Schedule(vsOutResult.Length, 1);      

首先,我们要初始化输出的NativeArray,类型为 VSOutBuf,这和CPURasterizer的顶点输出类型一致。

	public struct VSOutBuf
    {
        public Vector4 clipPos; //clip space vertices
        public Vector3 worldPos; //world space vertices
        public Vector3 objectNormal; //obj space normals
        public Vector3 worldNormal; //world space normals
    }

由于顶点处理输出的数据对于每帧每个draw call都是不同的,所以它其实是临时数据,我们制定其内存分配类型为Allocator.TempJob,这种分配类型比Persistent快很多,可以传递给Job,且是线程安全的。但是使用后需要Dispose它,至少在4帧之类Dispose。在这里我们是在渲染结束后Dispose的,所以没问题。
之后,我们实例化一个 VertexShadingJob。设置它的输入输出数据,然后使用vsJob.Schedule去调度它。所谓调度就是让Job System开始执行这个Job,但是该Job在调度方法返回后并没有执行完毕,需要使用Complete保证其已经执行完毕,Complete会让当前线程等待Job执行完成。这儿我们并没有Complete这个vsJob,因为它会被三角形Job所依赖,所以我们等待三角形Job完成就行。注意顶点Job调度时指定的数组长度,是vsOutResult.Length,也就是顶点数。这表示每个顶点都会执行一次该Job。

三角形Job调度

			TriangleJob triJob = new TriangleJob();            
            triJob.trianglesData = ro.jobData.trianglesData;
            triJob.uvData = ro.jobData.uvData;
            triJob.vsOutput = vsOutResult;
            triJob.frameBuffer = _frameBuffer;
            triJob.depthBuffer = _depthBuffer;
            triJob.screenWidth = _width;
            triJob.screenHeight = _height;                                    
            triJob.TextureData = ro.texture.GetPixelData<URColor24>(0);
            triJob.TextureWidth = ro.texture.width;
            triJob.TextureHeight = ro.texture.height;
            triJob.UseBilinear = _config.BilinearSample;
            triJob.fsType = _config.FragmentShaderType;
            triJob.Uniforms = Uniforms;
            JobHandle triHandle = triJob.Schedule(ro.jobData.trianglesData.Length, 2, vsHandle);
            triHandle.Complete();

            vsOutResult.Dispose();
  • 初始化和设置数据
    TriangleJob同样很简单的实例化和设置输入输出,它需要的数据更多些,注意我们将上一步顶点Job输出的vsOutResult作为输入传入了TriangleJob。另外TriangleJob需要传入frameBuffer和depthBuffer,这两个也是NativeArray,但其长度是屏幕的像素数。
  • 调度
    Schedule方法传入的数组长度是三角形数量,因此Execute方法获取到的索引是三角形索引,Job每次执行处理一个三角形。光栅化时会获取到需要处理的像素的实际位置,因此输入的数组的长度并不一定要匹配调度时的数组长度,具体还是看怎么用。Schedule的第二个参数是内部循环batch的数量,即一次执行多次Job,这样需要调度执行的总Job实例数就会减少,但是调度的粒度会变大,有可能不能充分并行执行,这个数一般从1开始增大测试,看效果。这边使用2是测试后找到比较好的值。第三个参数则是依赖Job的handle, 这儿TriangleJob依赖于VertexShadingJob,因此填入vsHandle。Job System会在执行TriangleJob之前确保VertexShadingJob先执行完成。
  • Complete
    调度之后,由于我们没有其他的操作了,就只能调用Complete()等待Job执行完成。如果使用Unity Profiler就会发现,主线程一直在等待Job完成。其实如果有其他操作,可以先做其他操作,再一帧的最后再调用Complete。
  • Schedule和Complete的时机
    Unity在GDC上的一个视频讲了如何有效的安排Schedule和Complete,这是一个Job System实现粒子系统的例子,更新流程如下。
    在这里插入图片描述
    job的schedule越早越好,而complete越晚越好。因为complete是等待完成。这儿粒子系统的情况下,可以延迟一帧,每一帧开始时执行complete,获取job计算好的前一帧的粒子数据,然后渲染前一帧的粒子。然后schedule这一帧的模拟:
    在这里插入图片描述

Job实现

VertexShadingJob

	[BurstCompile]
    public struct VertexShadingJob : IJobParallelFor
    {
        [ReadOnly]
        public NativeArray<Vector3> positionData;        
        [ReadOnly]
        public NativeArray<Vector3> normalData;        

        public Matrix4x4 mvpMat;
        public Matrix4x4 modelMat;
        public Matrix4x4 normalMat;
        
        public NativeArray<VSOutBuf> result;

        public void Execute(int index)
        {            
            var vert = positionData[index];
            var normal = normalData[index];
            var output = result[index];

            var objVert = new Vector4(vert.x, vert.y, -vert.z, 1);
            output.clipPos = mvpMat * objVert;
            output.worldPos = modelMat * objVert;            
            var objNormal = new Vector3(normal.x, normal.y, -normal.z);
            output.objectNormal = objNormal;
            output.worldNormal = normalMat * objNormal;
            result[index] = output;
        }
    }

顶点Job输入所有的顶点属性(UV除外),然后在Job中计算并输出光栅化和渲染阶段需求使用的属性值:NativeArray<VSOutBuf> result
对于输入的NativeArray由于是只读的,因此可以使用[ReadOnly]属性标记只读来提高性能。
计算需要的矩阵可直接使用Matrix4x4表示,因为Matrix4x4是blitable类型,所以每个Job实例都会copy一份进来使用。
顶点Job是一个ParallelFor Job,因此Execute方法会获取当前执行操作的索引值。注意计算时会将坐标和法线从左手系转换到右手系。最后计算结果保存到result中。前面说过,三角形Job会继续使用NativeArray result的内容,因此三角形Job是依赖于顶点Job的。

TriangleJob

代码较多,不贴了,请前往git查看。重点关注一下输入输出数据:

		[ReadOnly]
        public NativeArray<Vector3Int> trianglesData;  
        [ReadOnly]
        public NativeArray<Vector2> uvData; 
        [ReadOnly]
        public NativeArray<VSOutBuf> vsOutput;

        [NativeDisableParallelForRestriction]
        public NativeArray<Color> frameBuffer;
        [NativeDisableParallelForRestriction]
        public NativeArray<float> depthBuffer;

        public int screenWidth;
        public int screenHeight; 

        [ReadOnly]
        public NativeArray<URColor24> TextureData;   
        public int TextureWidth;
        public int TextureHeight;  

        public bool UseBilinear;   

        public ShaderType fsType;  

        public ShaderUniforms Uniforms;     

输入数据中的 trianglesData和uvData都是获取自JobRenderObjectData的NativeArray,都是ReadOnly的。另外一个输入数组是前面顶点Job输出的 vsOutBuf 数据,在这个Job中,它也是ReadOnly的。然后看输出的缓冲区,其实就是上面JobRasterizer中定义的颜色缓冲区和深度缓冲区NativeArray,先忽略上面的NativeDisableParallelForRestriction属性,后面会说。另外,对于纹理数据,上篇中说过,使用NativeArray TextureData。其他的还有一些blittable类型的数据,如屏幕宽高,纹理宽高,shader类型,Uniform等。

Execute中的数据获取

Job中的计算方法和CPURasterizer一样,不同的只是数据的类型。由于我们这儿是针对三角形计算的,所以调度时传入的是三角形数组的长度,因此Execute的索引就是三角形的索引。使用这个index去trianglesData中获取三角形数据,即一个Vector3Int,其每个元素都指向一个顶点。然后使用这些顶点的索引去获取每个顶点的数据,之后组装出我们内部使用的Triangle结构,进行光栅化和渲染。

		public void Execute(int index)
        {            
            Vector3Int triangle = trianglesData[index];
            int idx0 = triangle.x;
            int idx1 = triangle.y;
            int idx2 = triangle.z;

            var v0 = vsOutput[idx0].clipPos;
            var v1 = vsOutput[idx1].clipPos;
            var v2 = vsOutput[idx2].clipPos;                                  
                
            // ------ Clipping -------
            if (Clipped(v0, v1, v2))
            {
                return;
            }                

            // ------- Perspective division --------
            //clip space to NDC
            v0.x /= v0.w;
            v0.y /= v0.w;
            v0.z /= v0.w;
            v1.x /= v1.w;
            v1.y /= v1.w;
            v1.z /= v1.w;
            v2.x /= v2.w;
            v2.y /= v2.w;
            v2.z /= v2.w;                                            

            //backface culling                
            {
                Vector3 t0 = new Vector3(v0.x, v0.y, v0.z);
                Vector3 t1 = new Vector3(v1.x, v1.y, v1.z);
                Vector3 t2 = new Vector3(v2.x, v2.y, v2.z);
                Vector3 e01 = t1 - t0;
                Vector3 e02 = t2 - t0;
                Vector3 cross = Vector3.Cross(e01, e02);
                if (cross.z < 0)
                {
                    return;
                }
            }

            // ------- Viewport Transform ----------
            //NDC to screen space            
            {

                v0.x = 0.5f * screenWidth * (v0.x + 1.0f);
                v0.y = 0.5f * screenHeight * (v0.y + 1.0f);                
                v0.z = v0.z * 0.5f + 0.5f; 

                v1.x = 0.5f * screenWidth * (v1.x + 1.0f);
                v1.y = 0.5f * screenHeight * (v1.y + 1.0f);                
                v1.z = v1.z * 0.5f + 0.5f; 

                v2.x = 0.5f * screenWidth * (v2.x + 1.0f);
                v2.y = 0.5f * screenHeight * (v2.y + 1.0f);                
                v2.z = v2.z * 0.5f + 0.5f; 
            }

            Triangle t = new Triangle();
            t.Vertex0.Position = v0;
            t.Vertex1.Position = v1;
            t.Vertex2.Position = v2;                

            //set obj normal
            t.Vertex0.Normal = vsOutput[idx0].objectNormal;
            t.Vertex1.Normal = vsOutput[idx1].objectNormal;
            t.Vertex2.Normal = vsOutput[idx2].objectNormal;                
                                
            t.Vertex0.Texcoord = uvData[idx0];
            t.Vertex1.Texcoord = uvData[idx1];
            t.Vertex2.Texcoord = uvData[idx2];                    
                                            
            t.Vertex0.Color = Color.white;
            t.Vertex1.Color = Color.white;
            t.Vertex2.Color = Color.white;
            

            //set world space pos & normal
            t.Vertex0.WorldPos = vsOutput[idx0].worldPos;
            t.Vertex1.WorldPos = vsOutput[idx1].worldPos;
            t.Vertex2.WorldPos = vsOutput[idx2].worldPos;
            t.Vertex0.WorldNormal = vsOutput[idx0].worldNormal;
            t.Vertex1.WorldNormal = vsOutput[idx1].worldNormal;
            t.Vertex2.WorldNormal = vsOutput[idx2].worldNormal;

            RasterizeTriangle(t);
            
        }
Job中访问纹理

Job中不能访问Texture2D,

使用 NativeArray Texture2D.GetPixelData(int mipLevel);

可以获取到NativeArray。注意这儿的T,是按照类型T去解析获取到的数据,比如byte,则返回的数组中每项都是一个byteColor32则每项都是一个32位的颜色。具体T是啥,要看贴图的像素格式了,比如RBGA32贴图就可以用Color32。一开始我就是用Color32,但访问时总是数组越界,打印出来数组的长度,对于512的贴图,是32768,实际应该是262144。被坑了半天才发现,贴图默认是自动格式,这样在windows上是DXT1
在这里插入图片描述
DXT1一个像素可以压缩为原先的1/8,因此数组长度正好是原来的1/8
由于我使用的贴图都是24位的,因此直接设置为RGB 24bit。但是Unity没有 Color24的类型,那么就自己定义了一个。
另外由于这只是一个数组,需要使用 uv坐标去采样还得知道贴图的宽高,因此也要传进来。

关于[NativeDisableParallelForRestriction]

在Parallel Job里面进行光栅化三角形时,多个三角形有可能并行访问depth buffer/frame buffer的相同地方。这在多线程编程中属于race conditions,Job system内部会检测出来,会直接报错:

IndexOutOfRangeException: Index 219108 is out of restricted IJobParallelFor range [4392…4392] in ReadWriteBuffer.
ReadWriteBuffers are restricted to only read & write the element at the job index. You can use double buffering strategies to avoid race conditions due to reading & writing in parallel to the same elements from a job.

报错的代码为:

if(zp >= depthBuffer[index])
{
    depthBuffer[index] = zp;
  
    frameBuffer[index] = xxx;
}

看到这个错误,我觉得几乎做不下去了。好在Unity提供了一个attribute[NativeDisableParallelForRestriction]可以关闭这个检查:

		[NativeDisableParallelForRestriction]
        public NativeArray<Color> frameBuffer;
        [NativeDisableParallelForRestriction]
        public NativeArray<float> depthBuffer;

关闭后可以正常运行了,看起来挺正常。但是这样会有问题吗?

假设互相重叠的三角形A和B同时光栅化到某个像素的位置,A和B都通过了depth test,然后同时更新depth buffer,对于多线程来说这就是 race condition,有一半的几率写入错误的深度值,然后下面的frame buffer也有一半的几率写错。所以很遗憾,这么做确实有可能出问题。不过这种事情发生的机率还是很低的,首先我们有大量的三角形,然后CPU的核心数只有几个,所以两个重叠的三角形恰好同时在两个核心上运行线程的几率很低,并且两个三角形会光栅化大量的像素,因此正好重叠的像素同时写入的几率就更低了。而且重要的是,我们是在写渲染程序,而不是发射火箭。如果这里真的发生错误了,程序并不会因此crash,只是渲染结果偶然看上去有点瑕疵,然后很快就正常了,然后可能要等很久才能又发现一点点不对劲。所以极低几率的坏事发生并没什么大的影响,因此我认为可以这么使用。

Burst Compile

Burst is a compiler, it translates from IL/.NET bytecode to highly optimized native code using LLVM. It is released as a unity package and integrated into Unity using the Unity Package Manager.

仅仅是使用Job System,使用多核提升光栅化计算速度,性能就可以提升好几倍。但是我加上Burst Compile之后,性能直接起飞。
在这里插入图片描述

由于本文篇幅以及太长,所以不详细写Burst Compile了。实际我也仅仅是简单应用一下。给Job加上一个标签即可:

	[BurstCompile] 
    public struct TriangleJob : IJobParallelFor
	
	[BurstCompile]
    public struct VertexShadingJob : IJobParallelFor

当然加上[BurstCompile]后如果发现编译错误,那说明你的Job代码不满足Burst的要求,需要修改。
具体可查看 Burst的文档

  • 4
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值