初次尝试GPU Driven —— 大范围植被渲染
初次尝试GPU Driven —— 大范围植被渲染
GPU Driver简单概要,即把整体逻辑放到GPU上运行,解放CPU压榨GPU,初次尝试,记录一下研究过程。
渡神纪
塞尔达
塞尔达
塞尔达
在开放世界游戏里,经常会有大范围植被渲染,这些花花草草数量惊人,动辄数十上百万,光看这数字都能感觉到性能压力扑面而来,那么这些花花草草值得花费如此高昂成本去渲染吗?究竟是人性的扭曲,还是道德的沦丧?
先写个初版实现效果
初版实现很简单,通过一张纹理控制草的生长范围,把世界坐标映射到纹理UV,从纹理采样判断该坐标上是否长草,筛选完所有坐标后,GPU Instance就可以了。这个思路简单粗暴,一次渲染了整个场景的草,假设这个场景大小是1024 x 1024平方米,每平方米1颗草,那么一次就要渲染100百万颗草。接下来尝试优化这个过程,在游戏中,每一个瞬间并不能看到全部内容,视野外的看不见,被挡住的看不见,事实上大部分内容都看不见,在那些看不到的地方渲染的草是多余的,剔除掉这些多余的草则是本例的目的。
本例通过以下4个步骤进行剔除:
- 限定渲染范围
- 对渲染范围四叉分割LOD
- 视锥裁剪
- HizMap裁剪
1. 限定渲染范围
计算出视锥体的包围盒,用该包围盒覆盖的平面范围去采样,从而限定渲染范围,该步骤目的是为了让渲染范围仅跟视野范围相关而不会随着场景越大渲染范围越大。
部分代码:
void UpdateFrustumAABB(Vector3 coord)
{
if (coord.x < mFrustumAABB.x) { mFrustumAABB.x = coord.x; }
else if (coord.x > mFrustumAABB.z) { mFrustumAABB.z = coord.x; }
if (coord.z < mFrustumAABB.y) { mFrustumAABB.y = coord.z; }
else if (coord.z > mFrustumAABB.w) { mFrustumAABB.w = coord.z; }
}
...
var halfFovTan = Mathf.Tan(GrabDepthComp.SelfCamera.fieldOfView * Mathf.Deg2Rad * 0.5f);
var nearHalfH = halfFovTan * GrabDepthComp.SelfCamera.nearClipPlane;
var farHalfH = halfFovTan * GrabDepthComp.SelfCamera.farClipPlane;
var nearToT = nearHalfH * GrabDepthComp.SelfCamera.transform.up;
var nearToR = nearHalfH * GrabDepthComp.SelfCamera.aspect * GrabDepthComp.SelfCamera.transform.right;
var farToT = farHalfH * GrabDepthComp.SelfCamera.transform.up;
var farToR = farHalfH * GrabDepthComp.SelfCamera.aspect * GrabDepthComp.SelfCamera.transform.right;
var nearPosition = GrabDepthComp.SelfCamera.transform.position + GrabDepthComp.SelfCamera.transform.forward * GrabDepthComp.SelfCamera.nearClipPlane;
var farPosition = GrabDepthComp.SelfCamera.transform.position + GrabDepthComp.SelfCamera.transform.forward * GrabDepthComp.SelfCamera.farClipPlane;
mFrustumNearLB = nearPosition - nearToT - nearToR;
mFrustumNearRB = nearPosition - nearToT + nearToR;
mFrustumNearLT = nearPosition + nearToT - nearToR;
mFrustumNearRT = nearPosition + nearToT + nearToR;
mFrustumFarLB = farPosition - farToT - farToR;
mFrustumFarRB = farPosition - farToT + farToR;
mFrustumFarLT = farPosition + farToT - farToR;
mFrustumFarRT = farPosition + farToT + farToR;
// 计算视锥AABB
mFrustumAABB = new Vector4(GrabDepthComp.SelfCamera.transform.position.x, GrabDepthComp.SelfCamera.transform.position.z,
GrabDepthComp.SelfCamera.transform.position.x, GrabDepthComp.SelfCamera.transform.position.z);
UpdateFrustumAABB(mFrustumNearLB);
UpdateFrustumAABB(mFrustumNearRB);
UpdateFrustumAABB(mFrustumNearLT);
UpdateFrustumAABB(mFrustumNearRT);
UpdateFrustumAABB(mFrustumFarLB);
UpdateFrustumAABB(mFrustumFarRB);
UpdateFrustumAABB(mFrustumFarLT);
UpdateFrustumAABB(mFrustumFarRT);
mFrustumAABB.x = Mathf.Clamp(mFrustumAABB.x - FrustumOutDistance, 0, WorldSize);
mFrustumAABB.y = Mathf.Clamp(mFrustumAABB.y - FrustumOutDistance, 0, WorldSize);
mFrustumAABB.z = Mathf.Clamp(mFrustumAABB.z + FrustumOutDistance, 0, WorldSize);
mFrustumAABB.w = Mathf.Clamp(mFrustumAABB.w + FrustumOutDistance, 0, WorldSize);
效果:
2. 对渲染范围四叉分割LOD
虽然上述步骤限定了渲染范围,但范围依旧很大,比如站在高山上看远方,视野开阔,可视距离远,但并非可见之处都需要渲染高密度的草丛,因为远方的草丛看不见细节,只能看到一片绿色,该步骤将渲染范围拆分多个LOD,近处的高密度渲染,远处的低密度渲染。(通常会把长草的地面用绿色,从而达到在远处看,即使没有草丛也会看到一片绿,可见文章开头第三张图片)
四叉树分割算法大致思路是,若区块中心到相机的距离短于区块最长边,则该区块需要四叉分割并且LOD+1。
初始的区块LOD为0,随着多次细分,LOD逐渐变大,LOD越大则渲染越密集。
部分代码:
class FrustumTreeNode {
public int LOD;
public Vector4 AABB;
public FrustumTreeNode(int lod, Vector4 aabb)
{
LOD = lod; AABB = aabb;
}
}
...
var cameraCoord = new Vector2(GrabDepthComp.transform.position.x,
GrabDepthComp.transform.position.z);
mFrustumTreeA.Clear();
mFrustumTreeA.Add(new FrustumTreeNode(0, mFrustumAABB));
for (var lod = 0; lod != LODNumber; ++lod)
{
mFrustumTreeB.Clear();
for (var i = 0; i != mFrustumTreeA.Count; ++i)
{
var length = Mathf.Max(mFrustumTreeA[i].AABB.z - mFrustumTreeA[i].AABB.x,
mFrustumTreeA[i].AABB.w - mFrustumTreeA[i].AABB.y);
var center = new Vector2((mFrustumTreeA[i].AABB.x + mFrustumTreeA[i].AABB.z) / 2,
(mFrustumTreeA[i].AABB.y + mFrustumTreeA[i].AABB.w) / 2);
if ((cameraCoord - center).magnitude < length)
{
mFrustumTreeB.Add(new FrustumTreeNode(lod + 1, new Vector4(mFrustumTreeA[i].AABB.x, mFrustumTreeA[i].AABB.y, center.x, center.y)));
mFrustumTreeB.Add(new FrustumTreeNode(lod + 1, new Vector4(center.x, mFrustumTreeA[i].AABB.y, mFrustumTreeA[i].AABB.z, center.y)));
mFrustumTreeB.Add(new FrustumTreeNode(lod + 1, new Vector4(center.x, center.y, mFrustumTreeA[i].AABB.z, mFrustumTreeA[i].AABB.w)));
mFrustumTreeB.Add(new FrustumTreeNode(lod + 1, new Vector4(mFrustumTreeA[i].AABB.x, center.y, center.x, mFrustumTreeA[i].AABB.w)));
}
else
{
mFrustumTreeB.Add(mFrustumTreeA[i]);
}
}
Tools.Swap(ref mFrustumTreeA, ref mFrustumTreeB);
}
效果:
3. 视锥裁剪
前面两步剔除掉了大量额外渲染,但渲染范围是通过包围盒求出的,包围盒是一个长方体,它除了覆盖视野范围还覆盖了一些多余的范围,该步骤将剔除这些多余的范围。
思路是先求出视锥的6个面,随后在Compute Shader中,对每个单位求出包围盒,如果这个包围盒不在视锥体的6个面内,则该单位不可见。
首先每个单位的包围盒是很好计算的,只要有坐标就可以算出包围盒,而坐标在第一步中就能拿到。
其次,视锥体的6个面可通过UnityAPI算出,但据说引擎会调用到底层的C++导致一些不必要的开销,并且该接口需要Plane对象,导致后面对Shader传参不方便,所以手动算就好了。第一步已经计算出了视锥体的8个顶点,所以拿这8个顶点就能计算6个平面了。
部分代码:
C#
// 计算视锥裁剪面
// 左右
var lNormal = Vector3.Cross(mFrustumNearLB - mFrustumFarLB, mFrustumNearLT - mFrustumNearLB).normalized;
var rNormal = Vector3.Cross(mFrustumNearRB - mFrustumNearRT, mFrustumFarRB - mFrustumNearRB).normalized;
// 下上
var dNormal = Vector3.Cross(mFrustumNearLB - mFrustumNearRB, mFrustumFarLB - mFrustumNearLB).normalized;
var uNormal = Vector3.Cross(mFrustumNearRT - mFrustumNearLT, mFrustumFarRT - mFrustumNearRT).normalized;
// 近远
var nNormal = Vector3.Cross(mFrustumNearRB - mFrustumNearLB, mFrustumNearRT - mFrustumNearRB).normalized;
var fNormal = Vector3.Cross(mFrustumFarRB - mFrustumFarRT, mFrustumFarLB - mFrustumFarRB).normalized;
mFrustumPlanes[0] = lNormal; mFrustumPlanes[0].w = Vector3.Dot(lNormal, -mFrustumNearLB);
mFrustumPlanes[1] = rNormal; mFrustumPlanes[1].w = Vector3.Dot(rNormal, -mFrustumNearRB);
mFrustumPlanes[2] = dNormal; mFrustumPlanes[2].w = Vector3.Dot(dNormal, -mFrustumNearLB);
mFrustumPlanes[3] = uNormal; mFrustumPlanes[3].w = Vector3.Dot(uNormal, -mFrustumNearRT);
mFrustumPlanes[4] = nNormal; mFrustumPlanes[4].w = Vector3.Dot(nNormal, -mFrustumNearRB);
mFrustumPlanes[5] = fNormal; mFrustumPlanes[5].w = Vector3.Dot(fNormal, -mFrustumFarRB);
HLSL
bool IsCullByPlane(float3 coord, float width, float4 plane)
{
return dot(coord + float3(-width, -width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, -width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3(-width, width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3(-width, -width, width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, -width, width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, width, width), plane.xyz) + plane.w <= 0
&& dot(coord + float3(-width, width, width), plane.xyz) + plane.w <= 0;
}
bool IsCullByFrustum(float3 coord, float width)
{
return IsCullByPlane(coord, width, _FrustumPlanes[0])
|| IsCullByPlane(coord, width, _FrustumPlanes[1])
|| IsCullByPlane(coord, width, _FrustumPlanes[2])
|| IsCullByPlane(coord, width, _FrustumPlanes[3])
|| IsCullByPlane(coord, width, _FrustumPlanes[4])
|| IsCullByPlane(coord, width, _FrustumPlanes[5]);
}
效果:
1.HizMap裁剪
经过上述三个步骤,几乎剔除掉了所有多余单位,但在有些情况下,仍然有多余的单位被渲染。例如前面有一堵墙,墙后的单位看不见,但它仍然被渲染,只不过最终没有呈现在屏幕上,当前步骤则用于剔除墙后的单位。
HizMap的全名叫Hierarchical Z-buffer Map,它主要有两部分:
- 生成HizMap
- 使用HizMap
生成HizMap
将深度图生成Mipmaps,但该Mipmaps跟常规不一样的是,常规方法通过插值迭代每一层,而HizMap是通过取最大(或最小)的值迭代每一层。
使用HizMap
对每一个渲染单位,将包围盒映射到屏幕空间,再用屏幕空间包围盒大小计算出对应的LOD,之后用这个LOD和屏幕坐标采样HizMap,因为HizMap中记录的是最大(或最小)深度,所以采样的结果小于(或大于)当前单位的深度,则表示当前单位不可见,可以剔除。
下面用几张图概括HizMap的原理:
最终的HLSL
// 分布草
#pragma kernel BuildGrass
#pragma kernel HizMapInit
#pragma kernel HizMapCopy
#pragma kernel HizMapDebug
float2 _DispatchLimit;
// xy: origin
// zw: unit
float4 _GrassInputArgs;
// xy: 世界比贴图大小
// zw: 偏移距离
float4 _GrassMaskScale;
Texture2D<float4> _GrassMaskTexture;
AppendStructuredBuffer<float4> _GrassCoordOutput;
// Frustum
float4 _FrustumPlanes[6];
// HizMap
Texture2D<float4> _HizMap;
float4x4 _WToViewProj;
float4 _HizMapParams;
bool IsCullByPlane(float3 coord, float width, float4 plane)
{
return dot(coord + float3(-width, -width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, -width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3(-width, width, -width), plane.xyz) + plane.w <= 0
&& dot(coord + float3(-width, -width, width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, -width, width), plane.xyz) + plane.w <= 0
&& dot(coord + float3( width, width, width), plane.xyz) + plane.w <= 0
&& dot(coord + float3(-width, width, width), plane.xyz) + plane.w <= 0;
}
bool IsCullByFrustum(float3 coord, float width)
{
return IsCullByPlane(coord, width, _FrustumPlanes[0])
|| IsCullByPlane(coord, width, _FrustumPlanes[1])
|| IsCullByPlane(coord, width, _FrustumPlanes[2])
|| IsCullByPlane(coord, width, _FrustumPlanes[3])
|| IsCullByPlane(coord, width, _FrustumPlanes[4])
|| IsCullByPlane(coord, width, _FrustumPlanes[5]);
}
uint GetHizMapIndex(float2 boundsMin, float2 boundsMax)
{
float2 uv = (boundsMax - boundsMin) * _HizMapParams.x;
uint2 coord = ceil(log2(uv));
uint index = max(coord.x, coord.y);
return min(index,_HizMapParams.y-1);
}
float3 TransformToUVD(float3 coord)
{
float4 ndc = mul(_WToViewProj, float4(coord, 1));
ndc.xyz /= ndc.w;
ndc.xyz = (ndc.xyz + 1) * 0.5f;
ndc.z = 1.0f - ndc.z;
return ndc.xyz;
}
// Z: 1~0
bool IsCullByHizMap(float3 coord, float width)
{
float3 uvd0 = TransformToUVD(coord + float3(-width, -width, -width));
float3 uvd1 = TransformToUVD(coord + float3( width, -width, -width));
float3 uvd2 = TransformToUVD(coord + float3( width, width, -width));
float3 uvd3 = TransformToUVD(coord + float3(-width, width, -width));
float3 uvd4 = TransformToUVD(coord + float3(-width, -width, width));
float3 uvd5 = TransformToUVD(coord + float3( width, -width, width));
float3 uvd6 = TransformToUVD(coord + float3( width, width, width));
float3 uvd7 = TransformToUVD(coord + float3(-width, width, width));
float3 min0 = min(min(uvd0, uvd1), min(uvd2, uvd3));
float3 min1 = min(min(uvd4, uvd5), min(uvd6, uvd7));
float3 boundsMin = min(min0, min1);
float3 max0 = max(max(uvd0, uvd1), max(uvd2, uvd3));
float3 max1 = max(max(uvd4, uvd5), max(uvd6, uvd7));
float3 boundsMax = max(max0, max1);
uint mip = GetHizMapIndex(boundsMin.xy, boundsMax.xy);
float hizMapWidth = _HizMapParams.x / pow(2, mip);
float2 uv0 = min(hizMapWidth - 1, floor(boundsMin.xy * hizMapWidth));
float2 uv1 = min(hizMapWidth - 1, floor(boundsMax.xy * hizMapWidth));
float d0 = _HizMap.mips[mip][uv0].r;
float d1 = _HizMap.mips[mip][uv1].r;
return boundsMax.z < d0 && boundsMax.z < d1;
}
[numthreads(8, 8, 1)]
void BuildGrass (uint3 id : SV_DispatchThreadID)
{
if ((float)id.x < _DispatchLimit.x && (float)id.y < _DispatchLimit.y)
{
float2 worldCoord = _GrassInputArgs.xy
+ _GrassInputArgs.zw
+ _GrassInputArgs.zw * id.xy;
float2 maskIndex = floor(worldCoord * _GrassMaskScale.xy);
float4 maskValue = _GrassMaskTexture[maskIndex];
if (maskValue.b > 0)
{
float2 offset = maskValue.xy * 2.0f - 1.0f;
worldCoord.xy += _GrassMaskScale.zw*offset;
float4 oCoord = worldCoord.xxyy * float4(1, 0, 1, 0);
if (!IsCullByFrustum(oCoord.xyz, 0.5) &&
!IsCullByHizMap(oCoord.xyz, 0.5))
{
_GrassCoordOutput.Append(oCoord);
}
}
}
}
int _HizMapMip;
Texture2D<float4> _CameraDepth;
Texture2D<float4> _HizMapIn;
RWTexture2D<float4> _HizMapOut0;
RWTexture2D<float4> _HizMapOut1;
[numthreads(8, 8, 1)]
void HizMapInit (uint3 id : SV_DispatchThreadID)
{
if ((float)id.x < _DispatchLimit.x && (float)id.y < _DispatchLimit.y)
{
float2 index = floor(id.xy / _HizMapParams.zw);
float4 value = _CameraDepth[index];
_HizMapOut0[id.xy] = value;
_HizMapOut1[id.xy] = value;
}
}
[numthreads(8, 8, 1)]
void HizMapCopy (uint3 id : SV_DispatchThreadID)
{
if ((float)id.x < _DispatchLimit.x && (float)id.y < _DispatchLimit.y)
{
float2 uv = floor(id.xy * 2);
float a = _HizMapIn[uv ].r;
float b = _HizMapIn[uv + float2(1, 0)].r;
float c = _HizMapIn[uv + float2(0, 1)].r;
float d = _HizMapIn[uv + float2(1, 1)].r;
float v = min(min(a, b), min(c, d));
_HizMapOut0[id.xy] = v;
_HizMapOut1[id.xy] = v;
}
}
最终效果:
从上图Game视图左上角可以看到,渲染1024 x 1024 x 1的植被,经过剔除后,每帧渲染大概1100~4000之间,比起100万,简直少了太多。因为有HizMap,减少到0也不是不可能,Demo场景只是一个平面,简单摆放了一些遮挡,在实际游戏场景中,遮挡物远比Demo中复杂,HizMap能发挥更好的效果。
总结
游戏里放大量植被,既是人性扭曲,也是道德沦丧,万恶之源来自人类聪明的小脑壳,为什么会有这么多知识点,我只想躺平,我不想再学习了。
初次尝试GPU Driven —— 大范围植被渲染的更多相关文章
- 孤荷凌寒自学python第五十七天初次尝试使用python来连接远端MongoDb数据库
孤荷凌寒自学python第五十七天初次尝试使用python来连接远端MongoDb数据库 (完整学习过程屏幕记录视频地址在文末) 今天是学习mongoDB数据库的第三天.感觉这个东西学习起来还是那么困 ...
- 孤荷凌寒自学python第五十二天初次尝试使用python读取Firebase数据库中记录
孤荷凌寒自学python第五十二天初次尝试使用python读取Firebase数据库中记录 (完整学习过程屏幕记录视频地址在文末) 今天继续研究Firebase数据库,利用google免费提供的这个数 ...
- 孤荷凌寒自学python第五十一天初次尝试使用python连接Firebase数据库
孤荷凌寒自学python第五十一天初次尝试使用python连接Firebase数据库 (完整学习过程屏幕记录视频地址在文末) 今天继续研究Firebase数据库,利用google免费提供的这个数据库服 ...
- 微信小程序开发初次尝试-----实验应用制作(一)
初次尝试微信小程序开发,在此写下步骤以做记录和分享. 1.在网上找了很多资料,发现这位知乎大神提供的资料非常全面. 链接 https://www.zhihu.com/question/50907897 ...
- 20145330《Java学习笔记》第一章课后练习8知识总结以及IDEA初次尝试
20145330<Java学习笔记>第一章课后练习8知识总结以及IDEA初次尝试 题目: 如果C:\workspace\Hello\src中有Main.java如下: package cc ...
- 初次尝试使用jenkins+python+appium构建自动化测试
初次尝试使用jenkins+python+appium构建自动化测试 因为刚刚尝试使用jenkins+python+appium尝试,只是一个Demo需要很多完善,先记录一下今天的成果,再接再厉 第一 ...
- CPU与GPU区别大揭秘
http://blog.csdn.net/xiaolang85/article/details/51500340 有网友在网上提问:“为什么现在更多需要用的是 GPU 而不是 CPU,比如挖矿甚至破解 ...
- Gpu driven rendering pipelines & bindless texture
http://advances.realtimerendering.com/s2015/aaltonenhaar_siggraph2015_combined_final_footer_220dpi.p ...
- 初次尝试python爬虫,爬取小说网站的小说。
本次是小阿鹏,第一次通过python爬虫去爬一个小说网站的小说. 下面直接上菜. 1.首先我需要导入相应的包,这里我采用了第三方模块的架包,requests.requests是python实现的简单易 ...
- 跨平台渲染框架尝试 - GPU Buffer的管理(1)
buffer资源 下面来谈谈buffer的管理.buffer资源从广义上就是C语言的数组.如下图所示. 图 buffer的广义模型 在渲染管线中,无论是opengl还是dx或者其他的渲染api,都会提 ...
随机推荐
- LightOJ 1094
题意:就是求一个树的直径,也就是求任意两点的最大距离. 做法:跑两遍DFS,详见代码. #include<iostream> #include<cstdio> #include ...
- Go-用本地时间解析时间字符串
Go-用本地时间解析时间字符串 1. 指定本地时区 const ( gLocalTimeZone = "Asia/Shanghai" ) 2. 加载本地时区 var ( gLoca ...
- STM32CubeMX教程23 FSMC - IS62WV51216(SRAM)驱动
1.准备材料 开发板(正点原子stm32f407探索者开发板V2.4) STM32CubeMX软件(Version 6.10.0) 野火DAP仿真器 keil µVision5 IDE(MDK-Arm ...
- [转帖]Percolator - 分布式事务的理解与分析
https://zhuanlan.zhihu.com/p/261115166 Percolator - 分布式事务的理解与分析 概述 一个web页面能不能被Google搜索到,取决于它是否被Googl ...
- [转帖]龙芯 vs 飞腾:各种测试数据看国产CPU水平
https://zhuanlan.zhihu.com/p/99921594 2019年年末,龙芯.飞腾两大国产CPU巨头更是相继组织了规模宏大的年会,发布了新型桌面芯片及其整机产品,顿时硝烟四起.各大 ...
- git中git cherry-pick的使用
git中git cherry-pick的使用 A分支是从远端的开发分支dev拉取的 B分支是从远端的测试分支rel拉取的 现在我们遇见一个问题. 我们在A分支修改了代码.并且推送到了远端的dev分支. ...
- 对象中是否有某一个属性是否存在有三种方法 in hasOwnProperty Object.hasOwn
如何看某个对象中没有某一个属性 如果我们要检测对象是否拥有某一属性,可以用in操作符 var obj= { name: '类老师', age: 18, school: '家具' }; console. ...
- 通过dotnet-dump分析生产环境docker容器部署的应用问题
首先找到对应的docker id并exec进去,然后执行命令并更新apt包+下载procps和wget用于等下拉取dotnet-dump和查看线程 sed -i -e "s@deb.debi ...
- 深入学习C#系列文章01---C#3 革新写代码的新方式
C#3 几乎所有的新特性都是为LINQ服务的,但他们单独使用也非常有用,接下来我们来简单看看C#3 的几个新特性吧. 一.自动实现的属性-----编写由字段直接支持的简单属性,不再显得臃肿不堪. 之前 ...
- python从新手到安装指南
说到python我是跟着官方文档自学入门,本文适用于windows 操作系统,基于Inter和amd的CPU(涵盖市面80%的电脑) 下载和安装python 对于window操作系统的初学者,进入 p ...