移花接木—做一个简单的软件渲染器

移花接木—根据PBRT魔改出自己的软件渲染器

本文包含以下你可能感兴趣的内容:

  • 门外汉是如何学习computer graphic领域的
  • 从模仿到实践:PBRT给我带来了什么?
  • 如何自编一个软件渲染管线
  • 如何自编一个光线追踪器

一, 我是怎样学习计算机图形学知识的。

一切因缘都来源于搞事,坚持于初心,收束于思考。

起因:

    总有一些计算机领域交叉专业、或是非计算机专业的学生,对计算机硬件或软件工程专业的某一方面有特别的兴趣,比如机器学习、数据挖掘、计算机视觉、或是更根本的数据结构和算法,正好我也是其中之一。  

    我对计算机图形学的兴趣来源于硕士入学前,这个兴趣的产生可能有千百个可笑的理由,但所幸在我入学后坚持了下来。虽然目前我所在专业的研究领域和图形学毫不相干,但是也提供了我不少视野和动力学习自己从未接触过的领域。从这点来说,这个研究生读起来并不是血亏的~  

方法1-书籍:

    我的计算机图形学之旅始于读书,买下网店上计算机图形学教材,然后开始啃。我要特地指出,这是一个低效而不划算的办法。  

    第一是:当你面对各类水平参差不齐的教材,你很难找到一本全面而前沿的书籍作为你的入门参考。你不得不花费大量的时间,来串联和比较不同书籍的各个篇章,并妄图把那些各不相同的公式符号对应起来。  

    第二是:译文版的某些章节的确...让人费解,如果你是一个英语水平不错(或是熟练掌握有道词典)的同学,可以去找找英文版对照。

    在此我要指出,我认为比较不错的两个译文版的教材。  

    一个是《计算机图形学(第四版)》,Donald Hearn等著,蔡士杰和杨若瑜译,电子工业出版社。这本书是一本比较全面的教材,翻译的也不错。缺点是很多章节限于篇幅,对算法的实现并没有具体描述。  

    另外一个是《计算机图形学与几何造型导论》,Ronald Goldman著,邓建松等译,清华大学出版社。如果你是一位喜欢数学,有着公式推导强迫症的同学,你一定不要错过这本书。但是它的缺点也很明显,书如其名,是一本关于几何学的书籍,并不是那种百科全书。

方法1-PLUS:

    我们总希望能站在巨人的肩膀上,学得更快,站得更好,够得更远。那么,找一些前辈们推荐的书籍来读是事半功倍的方法。你可以在CSDN、知乎或豆瓣去找书单。  

    知乎上有几个比较有价值的提问可以做参考:
    1,求图形学算法好书推荐?
    https://www.zhihu.com/question/27525631/
    2,与游戏和图形学相关的数学和物理书
    https://www.zhihu.com/question/26574544/  

    我个人认为一本非常不错的国外教材是:《Fundamentals of Computer Graphics Third Edition》,它的封面是一只老虎,让人印象深刻。它的内容较新,对算法的细节实现有很多描述,章节横跨实时渲染管线和光线追踪算法,对初学者非常的友好。在拥有不同教材的条件下,你应该优先读这本书。  

    在这本书的基础上,你有两个选择:  

    1,先学离线渲染,主要是关于光线追踪算法的书籍。那么,你可能需要读一本非常重要的书籍:《Physically.Based.Rendering,.Second.Edition.From.Theory.To.Implementation》(现在他已经出到第三版了)。这本书从理论到代码,告诉你如何去实现一个开源的光线追踪器PBRT。这本书的内容非常丰富,而且你可以学习如何从细节上去实现算法。跟着书的步伐,一步步来实现代码,你会得到一个提升。当然,未必要把它完整的实现一遍,这未必有意义,你的目的是从中学到更多,开始明白如何构造自己的代码。  

    2,或者你是一个游戏编程爱好者,那么你不可能错过实时渲染的圣经:《Real-Time.rendering.3rd》。这本书阐述了近年来实时渲染领域的各种公式、算法和理论,并给出了论文出处。对于一个菜鸟来说,通读这本书是愚蠢的。我建议从中找自己感兴趣的内容,找到其给出的论文,了解原理,然后你可以用OpenGL或DX11来实现书中的算法。  

方法2-会议和论文:

    每个专业的领域都会有自己的权威期刊或会议,刊登世界上最前沿的理论文献。计算机图形学也有这种类似于“天下第一武道大会”的会议。

    1,首先要提出的是:SIGGRAPH一年一度的年会,是世界上最权威和专业的计算机图形图像领域的国际会议。会议上的每篇文献不仅包含了最新的科研成果,还包含了一系列该领域的相关文献作为参考。如果你是一个有过科研经历的人,一篇该会议的文献能够给你理清该方向近年来的发展脉络,并提供上百甚至更多的专业文献供你参考。

    2,其次就是SIGGRAPH Asia和Eurographics,也是非常重要的会议,含金量也很高。

    台湾国立清华大学的Ke-Sen Huang博士在他的网站总结了近年来重要国际会议的论文,你们可以访问: http://kesen.realtimerendering.com/ 来查询最新资料。

    无论如何,你现在已经掉入深坑,读不完的书,看不完的论文等着你。想想你感兴趣的方向,哪些学校、作者和课题组,有选择的学习图形学知识。

二,编程路上的人生导师—Physically.Based.Rendering

没有什么知识难点是打一遍代码解决不了的。如果有,那就两遍。

万事开头难:

    很多非计算机科班出身,乃至一部分计算机科学或软件工程专业的同学都纠结于一件事:脱离便利的API和函数库,我们如何用代码,从底层上实现书本上的公式和理论。

    如各位所见,我一个物理专业学生,同样困惑于这种问题。尽管我也能像某些同学一样,用python构造我的数学模型和物理公式,但对于从头编写一个渲染器而言,我可真是一筹莫展。

    幸好我还知道,小孩子都要从模仿走路开始,找一个格式规范、架构清晰、轻巧易用的渲染器来学习是最好的方法。这个时候,《Physically.Based.Rendering》对我起到了很大帮助。这本书结合开源的光线追踪器PBRT,从底层告诉了你,一个渲染器是如何实现的。

    这本书有几个优点:  

    1,易读性:本书采用了Literate Programming(文学编程法)书写代码和文字,你可以从每段代码中找到它的上下文,定义和引用。

    2,模块化:PBRT采用C++编写,尽量减少了各种第三方库的支持,他的几何数学库(包括点、向量、矩阵转换)和图元库(球体、圆柱、三角网络等)都是高度独立的。在下一章节,我就用它的基础模块搭建了自己的渲染器,供软件渲染管线和光线追踪算法使用。  

    3,开源性:它可以说是一本经典的教材。在编写渲染器的过程中,凡是你不懂的细节都可以通过查阅源代码来找到答案。你也可以在GitHub上找到更多关于PBRT的新模块或改版,来参考着实现你自己的功能。

    当你学习这本书中代码的实现时,你就可以了解到如何制作一个光线追踪器。同样的,触类旁通,这个过程也会增强你对实施渲染管线的启发和思考。

明确自己的目的:

    在我刚开始编写PBRT的时候,我有一个很可爱的想法,那就是先把代码编写一遍再复习。其实这个想法,太漫长太拖沓了。PBRT的代码虽然并不是那么特别大,但是它涉及到光线追踪算法和BRDF的很多方面。再加上它的代码实现,初学者想囫囵吞枣一样吃个通透是不可能的。

    所以,明确自己的目的,你就是来一步步学习的。你完全可以一章一段的啃,结合你自己的代码和兴趣,找重点来学。实际上,作者我并没从头到尾啃完PBRT这部书,对该书的学习是按照一边学算法一边查阅的方法进行的。

    总之,如果你实在不知道如何下手写渲染器的代码,又希望理解光线追踪算法是如何实现的,那么我推荐你学习这本书。

三,借助PBRT的模块来编写软件渲染管线:

新兴的公司总是通过模仿来学习,然后创新——某大厂实习卧谈会

1,为何要用C++实现渲染管线的过程?

    当我入门学习计算机图形学的时候,很多同学跟我推荐OpenGL的红蓝宝书,或是DX9、DX11的龙书。这很方便,我按照书上的步骤,一步步输入参数,就可以得出炫目的视觉效果,这很Cool!

    但是我想说的是,实际上我们使用的图形库和书本上的知识是有隔阂的。当我们输入一个参数时,我们只知道它从哪里来(可能是书本或者样例告诉你的),却不知道它为何要到那里去(下一个管线阶段在做什么?)。这种不协调的错觉,聪明的同学通过查阅书籍和资料可以自行领悟,但是对我,或是不实现一遍就不明白原理的“蠢”同学来说,简直是认知上的灾难。很多有用的工具帮我们翻过了困难的山峰,却没拉高我们的海拔,这是让人无法接受的。

    另外一个值得编写软件渲染管线的理由是,它是能够自由更改,并完全受你支配的。当你拆分渲染管线不同阶段的时候,你可能会发现,有些有意思的文章,完全可以通过一些聪明的做法来实现;而一些文章中没有提到的技术问题,在你脆弱不堪的管线里会无限放大!

    所以,打开你的IDE,用你喜欢的语言,从头开始写出一个渲染器,虽然对你而言很困难,但是却是有收获价值的。

2,如何组织你的渲染器?

  • 如何选择开发语言和平台?

        作为初学者,不考虑运行效率或者开发难度,你应该选择最感兴趣的一门语言做开发。尽管我有python的实战经验,但是对C++的兴趣让我将它做为了编程语言。
        而平台应该是你最方便使用的那个。鉴于VS的功能强大,以及手头缺少空闲的linux系统的资源,我选择了微软的windows平台。
    
  • 你的程序要包含哪些模块?

        你已经读了很多关于计算机图形学的书籍和代码了,现在当你闭上眼睛,即便是用脚思考也能够想到渲染管线中必不可少的模块。
    
    • 基本的几何数学运算库

          这是我们学习PBRT的第一个收获。你已经从PBRT中学到了如何编写和组织一个灵活而全面的数学库。点、向量、射线、交点等等几何学上的运算和检测,矩阵的变换和求逆等等。你可以自己重新编写,或者直接移植PBRT的代码(当然,注意它的开源协议!)。有了这些基本的函数库,接下来你可以编写出自己的几何图元结构(类)。  
      

      代码示范如下:

      //几何基础类:
      //geometry.h中有
      class Vector
      {public:
          float x,y,z;  //向量的各个分量
          Vector(){x = y = z = 0.f;} //构造函数初始化
          Vector operator+(const Vector &v) const 
          {
          return Vector(x + v.x, y + v.y, z + v.z);  
          }  // 重载+操作符,使向量之间可以相加
          ..... // 其他重载操作符或函数
      } 
      ..... // 其他类,如点、射线、法线等
      
      
      // transform.h中有:
      struct Matrix
      {
          float m[4][4]; //二维数组存储矩阵元素
          Matrix(){.....} // 初始化
          Mul(){.....}
          Transpose(){.....}
          Inverse(){.....}
          ..... // 矩阵的右乘、转置和求逆等操作 
      }
      class Transform
      {public:
          Matrix m, mInverse; // 4x4矩阵及其逆矩阵
          Transform(){.....} // 初始化
          Translate(){.....}
          Scale(){.....}
          Rotate(){.....}
          ..... // 矩阵转换操作,包括点和向量的平移、缩放和旋转等
      }
      .... //其他类,如四元数等
      
    • 几何图元结构

          几何图元有很多种类型。
      
          1,三角网格(Triangle Mesh):如果你想实现实时渲染管线的过程,那么三角网格是必不可少的。光栅化的核心要素都体现在三角形及其顶点上了。所以,你的三角网格结构至少需要包含所有顶点的位置,法线,纹理坐标等信息。此外,还需要包含一个关于顶点拓扑逻辑的索引。
      
          2,球体(Spheres):如果你是一个光线追踪算法的爱好者,那么最效率的渲染图元就是一个完美的圆球体。球体和射线的交点求法简单快速,交点上的法线、切线等属性也容易得到,光照模型(BRDF)的渲染速度会更快。有个特点是,平面也可以用直径很长的球面来模拟。
      
          3,其他:如果你不是特别需求的话,把其他图元结构往后放一放吧,时间是宝贵的。
      

      代码示范如下:

      class TriangleMesh
      {public:
          // 面、顶点、法线、纹理坐标的数量
          int faceNumber;    
          int vertexNumber;
          int normalNumber;
          int textureCoordinateNumber;
      
          Point *p;    //存储顶点坐标的动态数组
          Normal *n;    //存储法线的动态数组
          float *textureUV;    //存储纹理坐标的动态数组
      
          int *vertexIndex;    //存储顶点坐标的索引
          int *normalIndex;    //存储法线的索引
          int *textureIndex;    //存储纹理坐标的索引
      
          float Ka[3];     //材质信息等(也可以单独做成类)
          float Kd[3];
          float Ks[3];
          float Ke[3];
          ..... //其他有用的成员,构造函数初始化,以及自定函数
      }
      
      class Sphere
      {public:
          Point p;    //球体中心的位置
          float radius;    //球的半径
          ..... //构造函数初始化和自编函数。
      }
    • 相机,光照与纹理:

          在将图元输入渲染管线之前,我们还需要至少确定一个相机和一个光照类。相机类提供了渲染管线内的观察坐标系变换,裁剪和投影变换等信息。光照给光照模型(如Phong模型)提供了数据。纹理类则可以读取位图,将位图信息存储在动态数组中。  
      

      代码示范如下:

      class Camera 
      {public:
          Point eye;     // 相机位置
          Point gaze;    //观察点的位置
          Vector upView;     //相机上方的方向
          float oLeft, oRight, oTop, oBottom, oNear, oFar;     //视窗体的各个面
          foat nx,ny;     //屏幕长宽的像素数量
          Transform worldToEye;  //从世界坐标系到观察坐标系的转换
          .....  //构造函数初始化,其他坐标系之间的转换,如视窗体转换、投影转换等
          }
      
          class PointLight
          {public:
              Point p;     // 光源位置
              float illum[3];       //光源亮度
              .....
          }
      
          class Texture
          {public:
              BYTE *tb;     //存储bmp位图信息的动态数组
              int bmpW;     //位图宽、高
              int bmpH;
              int pBitCount;     //位图每个像素点的位数
              int lineByte;     //位图每行实际所占位数
              .....
          }
    • 固定软件渲染管线:

          当我们准备好模型,相机,光源等资源之后,我们需要做的就是将其放入渲染管线中,逐步实现三角形的光栅化。
      
      • 首先是输入顶点阶段:
        我们创建一个Vertex类,来存储Triangle Mesh中的顶点信息;再创造一个Triangle类,收集每个面上的三个vertex信息:

        class Vertex {顶点位置,法线方向,材质信息,纹理坐标等}
        class Triangle {三个vertex类,位置中心,面法线等}

        我们根据索引,将顶点属性装载入vertex,再装入Triangle,输入渲染管线。这样渲染管线就可以每次绘制一个三角形。

      • 然后是坐标系变换:
        根据你指定的camera类,转换每个三角形到视口坐标系,并在视窗体中做裁剪,用于插值阶段。

        ertex VertexTransform(const Vertex &v,const Camera &camera)
        Triangle TriangleTransform(const Triangle &tri,const Camera &camera)

      • 根据三角形内顶点的位置,做属性插值:
        你的顶点已经投影到屏幕坐标系上了,那么你需要考虑的是三角形内这些像素点的属性值。在这里,我计算了一个三角形的包围盒(BoundBox)内的每个像素点的质心坐标,判断像素点的是否渲染,并由此计算出像素点的属性。

        边线的隐式方程
        float MidPointDistance(int x, int y,const Point &p0,const Point &p1)
        设该像素点坐标为(i,j),求出质心坐标(a,b,c):
        float a = MidPointDistance(i, j, tri.vb.p, tri.vc.p) / MidPointDistance(tri.va.p.x, tri.va.p.y, tri.vb.p, tri.vc.p);
        float b = MidPointDistance(i, j, tri.vc.p, tri.va.p) / MidPointDistance(tri.vb.p.x, tri.vb.p.y, tri.vc.p, tri.va.p);
        float c = MidPointDistance(i, j, tri.va.p, tri.vb.p) / MidPointDistance(tri.vc.p.x, tri.vc.p.y, tri.va.p, tri.vb.p);
        像素点的属性由顶点和重心坐标求出:
        vertex pixel = a*vertexA+b*vertexB+c*vertexC;

      • 根据每个点的属性,计算颜色:
        这里我们简单计算了Phong光照模型.

        LightCompute(&pixel, pointlight, camera.eye);

      • 特别需要指出的是, 根据三角形创建二维包围盒,再根据视窗去裁剪这个包围盒,可以直接渲染裁剪后包围盒剩下的像素点,从而省略在视窗体中,对三角形的裁剪和重构。
    • 最后是输出阶段:
      你可以采用任何你想要的方法展示你的渲染结果,包括储存为图片,显示在窗口上或其他更cool的方法。在这里,我采用了windows下的GUI,用了一个古老而简单的函数去设置窗口上每个位置的颜色。

      SetPixel(窗口句柄,像素横坐标,像素纵坐标,颜色);

  • 如何操作编好的程序?

        我建议直接在VS中编写TriangleMesh以及camera和Light的属性,设置渲染管线的参数,并运行程序显示结果。为了能够渲染更多的图元,我增加了一个读取OBJ文件的函数,这样可以直接将maya或3dmax的网格文件渲染到窗口中去。
    

3,展示一下我的成果吧:一个简陋的环。

这是一个附加了卡通渲染和轮廓检测技术的渲染管线,有时间我再说说这个轮廓技术的在细节上的问题吧。

这里写图片描述

四,附加一个光线跟踪器:

另一个路径后面是别有洞天。

1,Ray Tracer 的几个要素:

    我们已经编好了渲染管线。现在我们基于已有的几何数学库、图元库和场景类,搭建一个简单的光线跟踪器。

    我们的目标是: 
    * 根据光线追踪的原理,打造一个RayTrace类。
    * 一个路径追踪(Path trace)和全局光照算法。
    * 计算像素着色的BRDF和蒙特卡洛采样。

2,RayTrace类的搭建:

    假设你已经了解光线追踪的基本原理,那么你可能明白一个RaTrace类至少需要:
    * 一个射线类,根据camera类产生射线。
    * 一个功能,读取场景中各个图元的顶点信息。
    * 一些公式,计算每个射线返回的颜色。
  • 射线类:

    经过学习PBRT,我们知道一个射线类需要至少一下参数:
    
            class Ray{射线原点o,射线方向d,顶点最小距离tmin,最大距离tmax,顶点当前交点距离t}
    
            当我们设定好camera类后,在相机观察坐标系View中,我们根据透视投影矩阵的信息,将每个像素的位置作为顶点o,将相机位置到像素的方向作为射线方向d,逐一产生每条射线,并测试它是否碰撞到任何待渲染图元。
    
            这时,我们有一个重要的函数需要了解,射线与图元的相交测试函数。这个函数读取了当前射线和待测图元的信息,测试了该图元与射线是否相交,计算了相交点及其点上的各类信息(法线,切线,材质,纹理坐标等)。
    
            这个函数可以设定为图元类中的函数,或者也可以设定为射线类的函数:
    
            图元类中的函数:
            Vertex Shape.Intersection(Ray &ray);
            或射线类中的函数:
            Vertex Ray.Intersection(Triangle &t);
            Vertex Ray.Intersection(Sphere);
    
    具体的实现可见不同形状与射线交点的计算公式。
    
  • 读取图元的功能:

            逐一读取图元信息,并与射线检测相交。在这个方面,你可以利用包围盒,背面消隐等功能,更加效率的检测射线与图元的交点。  
    
  • 计算射线着色的公式:

        根据每条射线返回的信息,你可以计算该像素的颜色。可以利用各种公式计算(比如Phong光照模型)。在这里我们采用路径追踪和全局光照算法,结合BRDF公式和蒙特卡洛采样计算结果。
    

3,路径追踪和全局光照:

  • 路径追踪和全局光照的基本原理:

            当我们产生的射线碰撞到物体时,我们在交点上产生新的射线,并将新射线与所有图元做相交测试,如果相交,则继续产生新的射线并测试相交。
            这样反复迭代,在每个由像素射出的射线与图元的交点上,会捕捉到该点收到的所有直接光照和间接光照。我们据此计算该像素点的着色。
            原理图如下,绿色线是射线,黄色是光源,红色和黑色是物体。
    

    这里写图片描述

            可见射线有以下几种碰撞情况:
            1,未碰撞到任何物体,返回背景色。
            2,碰撞到光源,返回光强Ke。
            3,一次碰撞后,碰撞到光源,返回光强与碰撞物体漫反射系数(也可以设为镜面反射)的乘积Ke·Kd。
            4,一次碰撞后,未碰撞到任何物体,返回背景色。
    

以上条件就是射线碰撞迭代函数的收敛条件,我们编写一个函数,在函数内反复调用自己。直到满足条件,返回碰撞信息。

            Color Radiance(Ray &ray)
            {
                射线与图元碰撞测试; 
                if(未碰撞) 返回背景色; 
                else if(碰撞次数达到设定值) 返回物体的辐照度Ke;
                else
                { 
                    碰撞次数+1;
                    产生新的射线 newRay;
                    返回:
                    碰撞物体反射系数X新射线返回的辐照度:
                    即kd·Rdiance(newRay);
                }
            }

4,BRDF与蒙特卡洛采样:

  • BRDF:

            我们做到这一步,肯定会产生一个疑点。如何在碰撞点产生一条射线,它是否有什么产生的规律呢,答案是有的。
            我们不断创造射线,在各个点上收集它周围所有的直接和间接光照,是有理论基础的。这个理论基础就是Bidirectional Reflectance Distribution Function,简称BRDF。
            它是一种主流的光照模型,描述了入射光与反射光之间的光能关系。BRDF的具体公式和含义在各大教材中均有详细描述,在这里我们只采用了BRDF计算漫反射材质的物体,这意味着:
            在任意一点,该点向任何方向反射出的出射光的光能都是相等的,所以,我们只要采取某种方法,收集该点所有的入射光(辐照度),就可以利用公式计算出,该点射线方向的出射光。
    

    这里写图片描述

  • 蒙特卡洛采样:

            紧接上面一章,我们需要收集一点所有的入射光,那么最好的办法就是以该点为原点,产生多条射线去碰撞光源,从而计算出入射光辐照度的积分。
            由此,我们采用蒙特卡洛法来解决这个积分问题。
            一个收集所有入射光的点,它的入射光的范围是一个半球体(如图),我们搭建一个局部坐标系:用法线表示w轴,该点的切线表示u轴,w与u轴的叉积得到v轴。
            在这样一个坐标系中,我们需要射出若干条射线,这些射线的在半球上的分布,一定需要符合均匀分布。我们可以编写一个均匀分布的随机数产生器,每次创造射线,先产生两个随机数ξ1和ξ2,再根据坐标系,由以下公式创造射线方向:
    

    这里写图片描述

            从而产生新的射线,收集直接和间接光源。
    

5,梳理和总结:

    最后我们总结一下,RayTracer类需要的计算过程:

     void Trace(所有图元信息shape, 相机camera)
     {
         根据camera类,产生w*h个射线:
        for(int i = 0;i<w*h;i++)
            {
            产生射线ray;
            计算颜色:
            pixelcolor(i)=Radiance(ray,shape);
            }
     }

    color Radiance(射线ray ,所有图元shape)
    {
        1,检测相交:
        for(所有图元)
            顶点信息vertex = 图元shape.intersection(ray);
        2,收敛条件(vertex,ray):返回当前碰撞图元的出射光。
        3,未收敛:
            根据vertex信息,建立局部坐标系:w,u,v.
            根据随机数,产生新的射线:newRay。
            计算新射线返回的入射光:Randiance(newRay)
            计算出射光并返回:
            return kd·Radiance(newRay)
    }

以上就一个简单光线追踪器的基本流程。
放个渲染后的康奈尔盒子:
这里写图片描述

五,题外话:

    虽然这个软件渲染器已经是我很久之前编写的东西了,但是回顾初学的历程,的确有不少困难,所幸有坚持和动力,还是硬生生的做了出来。
    这个过程,可以说是象征意义远大于实际,因为没有什么人会不使用硬件加速的渲染管线OpenGL或DX做实时渲染,而当前光线追踪也已经是并行计算,集群和高性能CPU编程的时代了,单纯的做个单机版的RayTrace也十分的低效。
    但经过这个流程,温故而知新,你总能在以后走的更好(大概?),面对以后你想复现的文献,你会更加得心应手一些。
    目前,该项目的经验和部分代码,我已经移植到一个新的有UI的软件“西娅西娅”中去了,这是我最近用ImGui编写了一个DX11基础的交互界面,附加光线追踪器,目的是更容易的载入场景,检查我编写的新的渲染代码是否有用,有兴趣的可以看我的GitHub:https://github.com/ChengGongXTU/SeaAsia/。

那么,各位,下期再见。

展开阅读全文
©️2020 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值