图形学3D渲染管线

DX和OpenGL左右手坐标系不同,会有一些差距,得出的矩阵会不一样;

OpenGL的投影平面不是视景体的近截面;

顶点(vertexs)

顶点坐标,颜色,法线,纹理坐标(UV),连线索引;

图元(primitives)

几何顶点被组合为图元(点,线段或多边形),图元装配;

片元(fragments)

图元被分几步转换为片元:图元被适当的裁剪,颜色和纹理数据也相应作出必要的调整,相关的坐标被转换为窗口坐标。最后,光栅化将裁剪好的图元转换为片元;

一、顶点数据(Vertex)

顶点坐标,颜色,法线,纹理坐标(UV),连线索引(三角形连线顺序-左右手坐标系顺序 不同);

顶点数据在流水管线中以图元处理:

点(GL_POINTS)、线(GL_LINES)、线条(GL_LINE_STRIP)、三角面(GL_TRIANGLES);

四边形顶点信息:顶点位置、颜色、UV、四个顶点顺序;

索引可以避免共享顶点间数据的多余——顶点缓存对象(Vertex Buffer Object,VBO)

二、顶点着色器(Vertex Shader)

1.矩阵变换

通过矩阵变换将世界坐标系下的顶点变换到视口坐标系下显示;

齐次变换:将一个原本是n维的向量用一个n+1维向量来表示(是不是很像投影);

互为逆矩阵:相乘结果为1;

转置矩阵:行列互换;

正交矩阵:行列向量互相垂直,正交矩阵的逆矩阵就是他的转置矩阵;

1)模型矩阵到世界矩阵

Local Coordinate——World Coordinate;

顶点在世界坐标系下可以进行平移(Translation),旋转(Rotation),缩放(Scaling)操作;

按顺序矩阵连乘得到世界矩阵下顶点的坐标;

如果进行缩放操作要考虑法线变换(非均匀缩放-逆矩阵的转置矩阵获得变换法线-切线空间);

OpenGL Normal Vector Transformation

2)世界矩阵到摄像机矩阵

World Coordinate——View Coordinate;

摄像机矩阵也叫观察矩阵;

摄像机的一些参数:

EyePosition:摄像机位置

FocusPosition:观察目标点(at)

UpDirection:摄像机上方向(不是Y,相对世界)

Near:近截面、Far:远截面

FOV(vertical field of view):垂直视场角

Aspect Ratio :屏幕纵横比

以上参数定义了视景体(台体);

顶点坐标转换到摄像机矩阵逆向思维,理解为将当前顶点平移摄像机当前位置向量的反向量;

摄像机*摄像机变换矩阵肯定会得到一个单位矩阵(坐标系都是单位矩阵);

摄像机当前的x,y,z轴组成的矩阵和摄像机变换矩阵互为逆矩阵;

通过摄像机参数求得摄像机的xyz轴向量;

由于正交矩阵的逆矩阵就是他的转置矩阵,再添加单位1;

将摄像机变换矩阵和平移矩阵相乘得到摄像机矩阵;

以上为左手坐标系下的摄像机矩阵;

3)摄像机矩阵到投影矩阵

View Coordinate——Projection Coordinate;

正交投影和透视投影;

在Dx中近截面就是投影平面;

正交投影每个坐标点只是丢弃了z坐标,就不推导了;

透视投影:通过相似三角形求出视景体内顶点x,y坐标在投影平面的相对位置,保留z坐标;

D3DXMATRIX matProject;
// 这个函数是设置正交投影矩阵
D3DXMatrixOrthoLH(&matProject, width, height, Znear, Zfar);
pD3dDevice->SetTransform(D3DTS_PROJECTION, &matProject);

OpenGL投影矩阵

从右到左将以上矩阵连乘获得局部空间到裁剪空间的变换矩阵:

V(clip) = M(projection)*M(view)*M(model)*V(local)

2.着色计算

Flat Shading——一个顶点代表三角形颜色,默认索引中第一个顶点颜色;

Gouraud Shading——逐顶点着色,只计算三个顶点关照信息,在光栅化阶段做插值得到各个片段光照信息,缺点:插值导致高光非线性;

Phong Shading——冯氏光照,逐片段着色(像素)法线插值出各个片元的法线信息,在片元着色器中使用法线、UV、位置等计算光照;

三、曲面细分着色器(Tessellation Shader)

可选阶段;

外壳着色器(Hull Shader)、镶嵌器(Tessellation)、域着色器(Domain Shader)

不创建高模,根据与摄像机距离使用镶嵌器细化模型,添加三角面(LOD);

四、几何着色器(Geometry Shader)

可选阶段;

以图元作为输入数据,可以创建销毁几何图元;

法线可视化——毛发效果;

根据距离摄像机远景动态调整多边形边数实现LOD效果;

公告牌技术(BillBoards)——3D图片替代模型,总是朝向摄像机;

五、图元组装(Primitive Assembly)

1.裁剪(Clipping)

1)线段裁剪

点面关系,线面关系,通过向量在面上的投影,以及点是否在矩形内;

八方位裁剪法;

2)三角形裁剪

平顶三角和平底三角形,判断出界平移顶点;

裁剪算法有Cohen-Sutherland算法Liang-Barsky算法Sutherland-Hodgman多边形裁剪算法

2.背面剔除(Back-Face Culling)

只和三角形与摄像机的距离有关,不依赖摄像机朝向;

根据三角形顶点顺序,使用左右手坐标系,叉乘求出法向量;

顶点顺时针法向量朝外剔除,逆时针朝内剔除;

3屏幕映射 (Screen Mapping)

1)透视剔除(Perspective Division)

将视景体空间顶点,除以w分量获得标准设备空间,-1到1的立方体空间(CVV-标准视体);

w分量保留的z坐标的信息,正交投影w分量为1,w分量也就是z值代表了深度信息;

OpenGL变换OpenGL投影矩阵

2)视口变换(View Transform)

通过执行透射除法获得归一化的设备坐标NDC(Normalized Device Coordinate);

z值在视口变换过程中被映射,OpenGL映射到[-1,1],DirectX[0,1];

NDC坐标映射到窗口坐标要通过平移和缩放过程(相机的宽高比);

视口矩阵:xy为窗口相对于屏幕的位置;

六、光栅化(Rasterization)

将图元离散为片元的过程;

图元覆盖一个片元(Overlap)——点采样(Point Sampling),像素中间点在三角形内;

超级采样和多重采样技术,涉及到抗锯齿;

1.三角形组装(Triangle Setup)

三角形组装会对顶点的输入数据(比如,颜色、法线、纹理坐标)进行插值,得到各个片段对应的数据值,为后面的片元着色器提供片元数据;

2.三角形遍历(Triangle Traversal)

遍历这些三角图元覆盖了哪些片元的采样点,随后得到该图元所对应的片元;

顶点数据插值获得片元的颜色,法线,UV,深度等信息,用于投射正交插值获得正确的透视颜色纹理等信息;

3.线段的扫描转化

  • Bresenham光栅化算法

  • 数字微分画线算法DDA (Digital Differential Analyzer)

    直线公式,斜率小于1,y增量为整数,大于1,x增量为整数;

4.多边形填充

求多边形的几何重心,以重心为顶点,向多变的各个顶点连线,分割为三角形;

重心颜色:所有三角形顶点颜色的平均值;

计算每个三角形的重心(三个顶点的x,y分别相加除以3);

将三角形重心坐标和面积关联起来;

sx += curx*curs;

sy += cury*curs;

重心的坐标为:tatal.s总面积

center.x = sx/total.s;

center.y = sy/total.s;

七、片元着色器(Fregment Shader)

色彩混合:颜色的RGB值相加,用于混合所有光照效果

色彩调制:色彩乘法,RGB值分别相乘,相当于给RGB值都乘以一个系数,将颜色变亮或变暗;

1.Phong光照模型

1)环境光

用来模拟全局光照效果;在物体光照信息基础上叠加较小的光照常量;

2)漫反射

光线进入物体内部重新散射出来,看做均匀分部所以和观察者位置无关;只影响亮度;

  • 兰伯特余弦定律(Lambert Consine Law)

    取决表面法线和光线的夹角,夹角越大分量越小,漫反射越小;90度漫反射几乎为0;

  • 半兰伯特模型(Half Lambert)

    v社做半条命时提出,改变物体暗区域的光照信息;

    将漫反射系数从[0,1]改为[0.5,1],提高暗部的亮度信息;

    //法线和光线的夹角,
    float diflight = dot(s.Normal,lightDir);
    float hLambert = difLight * 0.5 + 0.5;

3)镜面反射高光

和观察者的位置有关,不同角度观察结果不同;

  • Phong光照模型

光线反射向量和观察向量的夹角;

高光指数:e

在夹角大于90度的情况,会造成高光丢失现象,光线会不连续,有明显的明暗分界线;

  • Blinn-Phong光照模型

光线向量和观察向量的中间位置(半角向量)和法线的夹角;

在任何角度观察,夹角都不会大于90度;不会出现高光不连续现象;

  • 材质

    struct Material
    {
    vector3 ambient; //环境光
    vector3 diffuse; //漫反射
    vector3 Specular; //镜面反射
    vector3 emissive; //自发光
    float e; //镜面反射系数
    }

    物体最终颜色 = 环境光结果*环境光反射系数 + 漫反射结果*漫反射系数+镜面反射结果(计算了高光指数)*镜面反射系数+材质自发光颜色*自放光系数;

2.纹理贴图 (Textures)

纹理映射,将图像信息映射到三角形网格;

凹凸贴图(bump mapping)、法线贴图(normal mapping)、高度纹理(height mapping)、视差贴图(parallax mapping)、位移贴图(displacement mapping)、立方体贴图(cubemap)、阴影贴图(shadowmap);

  • UV坐标的寻址方式

归一化到[0,1]之间,像素大小为2的次方,方便计算处理;

寻址方式也叫平铺方式:重复寻址(repeat)、边缘钳制寻址(clamp)和镜像寻址(mirror);

uv超出0-1,该如何寻址(就是图片的平铺,边缘像素扩展,镜像);

  • 纹理采样方式

纹理像素和图元像素不是一一对应,要用到纹理的滤波方式;

点过滤(point)、线性过滤(linear)、最近领点过滤(nearest neighbor point)和双线性过滤(bilinear),Unity的Trilinear滤波的技术;

  • 法线贴图 (Normal Mapping)

物体空间(object space)和切线空间(tangent space)

根据物体空间计算的法线,在物体旋转移动后回得到错误的光照信息;

切线空间:相对于顶点坐标存储计算;

顶点本身法线为N轴,模型给定定义一条和该顶点相切的切线T轴,N和T叉乘得到B轴;

法线(N)、切线(T)和副切线(B) ,三个轴组成切线空间;

法线贴图就是在切线空间中记录了法线扰动的方向;

以顶点法线N为z轴坐标系,扰动后的法线z轴也总是朝向(0,0,1),所以得得到法线贴图总是淡蓝色(RGB);

法线纹理最终值需要做个映射,由于维度向量取值[-1,1],纹理通道范围在[0,1],最终记录结果为:(normal+1)/2;

切线空间坐标系到世界坐标系的转换矩阵

物体移动旋转时,法线乘以这个矩阵就可以得到改变后的法线;

因为z总是朝向(0,0,1)纹理就可以直接记录xy——纹理压缩:DXT1,DXT5;

3.锯齿和抗锯齿 (Aliasing and Anti-aliasing)

  • 超级采样抗锯齿 (Super-Sampling Anti-aliasing——SSAA)

    将原图分辨率放大一倍,再采样;光栅化和片元着色都是原来的4倍,渲染缓存也是4倍;

  • 多重采样抗锯齿 (Multi-Sampling Anti-aliasing——MSAA)

    每个片元有多个采样点,计算采样点的覆盖率(Coverage),光栅化阶段计算采样点覆盖率,在片元着色器计算颜色值后乘以这个覆盖率;

    MSAA和延迟渲染(deferred render)不兼容(延迟渲染需要Geometry 和Lighting两个Pass,lighting阶段无法通过GBuffer获得片元覆盖率);

4.阴影 (Shadows)

光照烘焙获得Shadowmap;先光照烘焙获得深度信息,再通过阴影贴图判断那些片元落在阴影中;

Shadowmap的精度会导致阴影粉刺,需要便宜深度来消除粉刺现象;

阴影锯齿通过百分比渐进过滤(PCF)实现软化阴影(softshadow);

八、测试和混合(Tests & Blending)

1.裁切测试 (Scissor Test)

裁切测试可以避免当视口比屏幕窗口小时造成的渲染浪费问题;一般默认不开启,

2.Alpha测试 (Alpha Test)

​ 片段着色器中丢弃alpha值小于0.1的片段;

  • Early-Z Culling

    硬件厂商用来加速渲染的手段;在片元着色之前提出被遮挡的片元;

    但是生效要求只能通过光栅化插值得到深度,不能再片元着色器阶段去修改深度缓冲;

3.模板测试 (Stencil Test)

模板测试有一个对应的缓存, 即模板缓存(Stencil Buffer), 用于记录所有像素的模板值, 默认值为0;

片元携带的参考值和模板缓存中的值比较,满足比较函数,调用操作函数更新模板值;

  • 模板值: 模板缓存中已经存在的值

  • 参考值: 在渲染该物体前, 由程序设置的指定值

  • 比较函数: 决定如何将两个值作比较的函数

  • 操作函数: 定义通过或者不通过测试后对模板值的更新操作

Unity中模板测试不可编程,可配置管线阶段;

Stencil
{
Ref refValue //参考值
Comp always //比较函数
Pass keep //模板测试和深度测试都通过后的操作
Fail keep //模板测试和深度测试都未通过后的操作
ZFail keep //模板测试通过而深度测试未通过后的操作
WriteMask 255 //使用参考值更新模板值之前, 在模板值与掩码按位与之后再更新 255代表不做处理
ReadMask 255 //读取模板值后, 将其与掩码按位与之后再与参考值作比较 255代表不做处理
} /*
UnityEngine.Rendering.CompareFunction比较函数枚举
0(Disabled): 关闭模板测试, 等同于全部通过测试, 经过测试发现不是真的关闭.
1(Never): 全部不能通过测试
2(Less): 待比较的值小于缓存中的值时通过测试
3(Equal): 待比较的值等于缓存中的值时通过测试
4(LessEqual): 待比较的值小于等于缓存中的值时通过测试
5(Greater): 待比较的值大于缓存中的值时通过测试
6(NotEqual): 待比较的值不等于缓存中的值时通过测试
7(GreaterEqual): 待比较的值大于等于缓存中的值时通过测试
8(Always): 全部通过测试, 默认值
*/ /*
UnityEngine.Rendering.StencilOp操作函数枚举
0(Keep): 保持模板缓存中的值不变
1(Zero): 将模板缓存中的值置为0
2(Replace): 使用参考值替换模板缓存中的值
3(IncrementSaturate): 使模板缓冲区值增大, 最大限制为可表示的最大无符号值
4(DecrementSaturate): 使模板缓冲区值减小, 最小限制为0
5(Invert): 对模板缓冲区值按位求反
6(IncrementWrap): 与IncrementSaturate类似, 只是达到最大后继续增大将重新设置为 0
7(DecrementWrap): 与DecrementSaturate类似, 只是达到最小后继续减小将重新设置为可表示的最大无符号值
*/

4.深度测试 (Depth Test)

比较当前片段的深度值是否比深度缓冲中预设的值小(默认比较方式),如果是更新深度缓冲和颜色缓冲;否则丢弃片段不更新缓冲区的值;

Early-Z Culling也是利用Z-Buffer的技术来进行深度测试的,只不过该测试是在片段着色器之前进行;

深度测试是可配置的阶段——ZTest和ZWrite

  • Z-Fighting

深度缓冲精度不够,深度值相近的片元会造成重叠模糊问题:

解决:物体不要靠太近;使用高精度的深度缓冲;

  • 隐藏面消除 (Hidden Surface Removal, HSR)

前面的图元组装裁剪,背面拣选,和Z-Buff都属于HSR,裁剪针对图元,Z-Buffer针对像素点;目的都是为了减少到达片元着色器的片元个数,提高渲染性能;

1)视椎体剔除 (Viewing-Frustum Culling)

利用物体包围盒来做交差检测,常见的包围盒有轴对齐包围盒(AABB)和有向包围盒(OBB)两种;

需要是由高效数据结构来提升碰撞检测的效率——八叉树(OcTree)、二分空间划分(Binary Space Partitioning)、四叉树(Quad Tree)、场景图(Scene Graphs)、kd树(K-Dimensional Tree)和层次包围(Bounding Volume Hierarchies);

计算:

点法式判断三维空间点和面的关系,裁剪掉不在视景体内的包围盒顶点;

求视景体六个面:

通过投影矩阵,求出投影和摄像机的逆矩阵,反向求出视景体对应面;

求逆矩阵——行列式,代数余子式,伴随矩阵,行列式的值,有固定公式;

近截面裁剪:

三角形和面的关系,用向量表示三角形和面;两条共起点的向量可以表示三角形;分解成线和面的关系;

三角形和近截面的关系:

1个角在视景体内——偏移另外两个顶点到视景体平面上;

2个角在视景体内——三角形拆分为2个三角形,注意三角形顶点连线顺序;

2)入口剔除 (Portal Culling)

将室内的门或者窗户看做视椎体来进行裁剪;实现"笼中窥梦"的效果;

3)遮挡剔除 (Occlusion Culling)

通过离线烘焙的犯法来预先计算出潜在可视集合(Potentially Visible Set,PVS);

PVS记录了每个地形块(Tiles)可能看到的物体的集合,用于运行时查找计算;

场景划分为小地形块,每个块上随机取N个采样点;

从采样点发射射线获取场景中和射线相交的物体,记录物体ID;

根据摄像机位置,使用采样点几率的物体id列表进行渲染;

采样精度和烘焙效率问题;

6.Alpha混合 (Alpha Blending)

经过前面所有的测试才能进入Alpha混合阶段,一个测试过不了都到不了Alpha混合;

Alpha混合实现物体半透明效果,渲染顺利非常重要,可能需要手动改Queue(渲染队列)的值;

  • 渲染顺序:

先渲染不透明物体,从前往后渲染;——不透明物体渲染前先进行深度检测,先后近的物体,远处物体通不过深度检测,就不用进行深度写入操作;

再渲染半透明物体,从后往前渲染;——半透明物体渲染需要知道前一层的颜色信息进行混合,先渲染远处物体,在画近处物体时可以通过颜色缓存获取前一层颜色信息;

画家算法:先画物体会被后画物体覆盖;

Unity ShaderLab中渲染队列设置;

Queue 其他预定义的值为:Background = 1000 , AlphaTest = 2450,Overlay = 4000。默认值是Geometry =2000;

ShaderLab默认开启深度测试和深度写入;

//将本 Shader 计算出的颜色值(源颜色值,即蓝色) * 源Alpha值(0.6) + 目标颜色值(可以理解为背景色) * (1-0.6)
Blend SrcAlpha OneMinusSrcAlpha
// Transparent (透明) = 3000,值越小越先渲染,而后渲染( Queue 值大)的物体会覆盖先渲染的物体
Tags {"Queue" = "Transparent"} ZTest LEqual //小于等于
ZWrite On //打开
//ZTest 可取值为:Greater , GEqual , Less , LEqual , Equal , NotEqual , Always , Never , Off,默认是 LEqual,ZTest Off 等同于 ZTest Always
//ZWrite 可取值为:On , Off,默认是 On
  • 顺序无关半透明算法(Order-independent transparency,OIT)

深度剥离(Depth Peeling)

双向剥离(Dual Depth Peeling)——两个方向剥离,一个从前往后,一个从后往前,两个方向效率提高;

图形学3D渲染管线学习的更多相关文章

  1. Directx11学习笔记【九】 【转】 3D渲染管线

    原文地址:http://blog.csdn.net/bonchoix/article/details/8298116 3D图形学研究的基本内容,即给定场景的描述,包括各个物体的材质.纹理.坐标等,照相 ...

  2. Directx11学习笔记【九】 3D渲染管线

    原文:Directx11学习笔记[九] 3D渲染管线 原文地址:http://blog.csdn.net/bonchoix/article/details/8298116 3D图形学研究的基本内容,即 ...

  3. PythonOCC 3D图形库学习—创建立方体模型

    Open CASCADE(简称OCC)平台是是一个开源的C++类库,OCC主要用于开发二维和三维几何建模应用程序,包括通用的或专业的计算机辅助设计CAD系统.制造或分析领域的应用程序.仿真应用程序或图 ...

  4. Three.js 3D特效学习

    一.Three.js基本介绍 Three.js是JavaScript编写的WebGL第三方库.提供了非常多的3D显示功能.Three.js 是一款运行在浏览器中的 3D 引擎,你可以用它创建各种三维场 ...

  5. 【Unity 3D】学习笔记三十五:游戏实例——摄像机切换镜头

    摄像机切换镜头 在游戏中常常会切换摄像机来观察某一个游戏对象,能够说.在3D游戏开发中,摄像头的切换是不可或缺的. 这次我们学习总结下摄像机怎么切换镜头. 代码: private var Camera ...

  6. 3D数学学习笔记——笛卡尔坐标系

    本系列文章由birdlove1987编写.转载请注明出处. 文章链接: http://blog.csdn.net/zhurui_idea/article/details/24601215 1.3D数学 ...

  7. 【Unity 3D】学习笔记三十:游戏元素——游戏地形

    游戏地形 在游戏的世界中,必然会有非常多丰富多彩的游戏元素融合当中. 它们种类繁多.作用也不大同样.一般对于游戏元素可分为两种:经经常使用.不经经常使用.经常使用的元素是游戏中比較重要的元素.一般须要 ...

  8. [转]3D渲染管线

    转自:http://tgerm.org/SRP/ 在3D中有两种渲染管线,分别是图形渲染管线和GPU渲染管线. 图形渲染管线 <Render-Time Rendering Third Editi ...

  9. PythonOCC 3D图形库学习—导入STEP模型

    PythonOCC comes with importers/exporters for the most commonly used standard data files format in en ...

随机推荐

  1. Luogu P1850 [NOIp2016提高组]换教室 | 期望dp

    题目链接 思路: <1>概率与期望期望=情况①的值*情况①的概率+情况②的值*情况②的概率+--+情况n的值*情况n的概率举个例子,抛一个骰子,每一面朝上的概率都是1/6,则这一个骰子落地 ...

  2. POJ 3692 Kindergarten(二分图最大独立集)

    题意: 有G个女孩,B个男孩.女孩彼此互相认识,男孩也彼此互相认识.有M对男孩和女孩是认识的.分别是(g1,b1),.....(gm,bm). 现在老师要在这G+B个小孩中挑出一些人,条件是这些人都互 ...

  3. 使用Magisk+riru实现全局改机

    前言 提到全局改机,我们想到修改的不是修改Android源码就是利用Xposed改机,前者成本太高,后者只能修改Java层的数据不够彻底.magisk是Android平台上功能强大的工具,利用它可以随 ...

  4. Windows内核中的CPU架构-6-中断门(32-Bit Interrupt Gate)

    Windows内核中的CPU架构-6-中断门(32-Bit Interrupt Gate) 中断门和调用门类似,也是一种系统段.同样的它也可以用来提权. 中断门: 虽然中断门的段描述符如下: 但是中断 ...

  5. 分布式事务(四)之TCC

    在电商领域等互联网场景下,传统的事务在数据库性能和处理能力上都暴露出了瓶颈.在分布式领域基于CAP理论以及BASE理论,有人就提出了柔性事务的概念.在业内,关于柔性事务,最主要的有以下四种类型:两阶段 ...

  6. SpringMVC配置知识点

    SpringMVC原生知识点 通过idea新建一个SpringMVC的Project(新建普通的项目就行了) 填写完之后Finish就行了 (实际开发不会这么用,这么做是为了理解!) 然后就是Spri ...

  7. 攻防世界 WEB 高手进阶区 upload1 Writeup

    攻防世界 WEB 高手进阶区 upload1 Writeup 题目介绍 题目考点 文件上传漏洞 一句话木马 中国菜刀类工具的使用 Writeup 使用burpsuite抓包 可见只是对上传文件的后缀进 ...

  8. virtualbox + vagrant 安装centos7 以及 vagrant up下载太慢的解决方案

    下载安装 virtualbox下载 vagrant下载 下载启动镜像vagrant up有下载过慢的问题,可以到网页vagrant镜像仓库,找到自己需要的镜像,选择virtualbox版本下载 下载好 ...

  9. 【JAVA】笔记(2)---面向过程与面向对象;类,对象;实例变量,引用;构造方法;

    面向过程与面向对象: 1.面向过程思想的典型栗子是C语言,C语言实现一个程序的流程是:在主函数中一步一步地罗列代码(定义子函数来罗列也是一样的道理),以此来实现我们想要的效果: 2.面向对象思想的典型 ...

  10. Python 官方研讨会:彻底移除 GIL 真的可行么?

    作者:Łukasz Langa 译者:豌豆花下猫,来源:Python猫 原文:https://lukasz.langa.pl/5d044f91-49c1-4170-aed1-62b6763e6ad0 ...