Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

原文:Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

代码工程地址:

https://github.com/jiabaodan/Direct12BookReadingNotes



假设我们没有使用曲面细分阶段,几何着色器阶段就是在顶点着色器和像素着色器之间的一个可选的阶段。几何着色器输入的是基元,输出的是一个基元列表;假如我们绘制的是三角形列表,那么几何着色器就是对每个三角形进行运算:

for(UINT i = 0; i < numTriangles; ++i)
OutputPrimitiveList = GeometryShader(T[i].vertexList );

几何着色器的主要好处就是,它可以创建或删除几何体,所以它可以基于GPU实现一些很有意思的效果。比如可以将输入的基元扩展为更多的基元输出,或者根据某些条件不输出部分基元。

值得注意的是几何着色器输出的基元类型不能和输入的类型相同,所以常见的程序就是将一个顶点扩展成一个方块。

输出基元通过顶点列表来定义,顶点位置必须变换到其次裁切坐标系中。



学习目标

  1. 学习如何编写几何种色器程序;
  2. 学习公告牌效果如何被几何着色器高效的实现;
  3. 识别自动输出的基元ID和一些其它的应用;
  4. 学习如何创建和使用纹理数组,理解为什它们有用;
  5. 学习为什么alpha-to-coverage可以帮助解决透明切口的抗锯齿问题。


1 几何着色器编程

几何着色器编程很像顶点和像素着色器,但是有一些不同的地方。下面的代码展示了它的基本形式:

[maxvertexcount(N)]
void ShaderName (PrimitiveType InputVertexType InputName [NumElements],
inout StreamOutputObject<OutputVertexType> OutputName)
{
// Geometry shader body…
}

首先要声明单词调用时,输出的顶点的最大数量;通过在函数声明前添加下面语句:

[maxvertexcount(N)]

输出的顶点数量在每次调用时是可变的,但是不能超过最大值。以优化为目标,最大值应该越小越好。[NVIDIA08]写明当最大值在1 ~ 20时,运行效率最高;当值为27~40时,运行效率会降低50%。在实践中基于上述限制条件来应用是比较困难的,但是[NVIDIA08]是在2008年发表的,所以现在情况会好一些。

几何着色器有2个参数:输入和输出。输入参数是一个用来定义基元的顶点列表。顶点类型是顶点着色器返回的顶点类型。输入参数必须要有一个基元类型前缀,它可以是下面的值:

  1. point:输入的是点;
  2. line:输入的是线(lists or strips);
  3. triangle:三角形(lists or strips);
  4. lineadj:邻接线(lists or strips);
  5. triangleadj:邻接三角形(lists or strips)。

输入到几何着色器中的基元是完整的基元,所以不用关心是lists or strips。如果是strip代表顶点会被多个三角形共用,也就会被几何着色器执行多次。

输出参数会有inout修饰符。并且它是流类型,它保存了输出的顶点列表。几何着色器程序通过内置的Append方法添加顶点到输出流列表中:

void StreamOutputObject<OutputVertexType>::Append(OutputVertexType v);

流类型是一个模板类型,模板参数用来指定输出顶点的顶点类型,有三种可能的流类型:

  1. PointStream:顶点列表定义点列表;
  2. LineStream:顶点列表定义线strip;
  3. TriangleStream:顶点列表定义三角形strip。

对于线和三角形 它们是strip类型。对于线和三角形列表,可以通过调用内置的RestartStrip函数来模拟:

void StreamOutputObject<OutputVertexType>::RestartStrip();

比如如果你想输出三角形列表,可以在每次添加3个顶点后,调用这个函数。

下面是一些定义几何着色器签名的例子:

// EXAMPLE 1: GS ouputs at most 4 vertices. The input primitive is a
// line.
// The output is a triangle strip.
//
[maxvertexcount(4)]
void GS(line VertexOut gin[2],
inout TriangleStream<GeoOut> triStream)
{
// Geometry shader body…
} // // EXAMPLE 2: GS outputs at most 32 vertices. The input primitive is
// a triangle. The output is a triangle strip.
//
[maxvertexcount(32)]
void GS(triangle VertexOut gin[3],
inout TriangleStream<GeoOut> triStream)
{>>>>>>>>>>>>>>>>>>
// Geometry shader body…
} // // EXAMPLE 3: GS outputs at most 4 vertices. The input primitive
// is a point. The output is a triangle strip.
//
[maxvertexcount(4)]
void GS(point VertexOut gin[1],
inout TriangleStream<GeoOut> triStream)
{
// Geometry shader body…
}

下面的几何着色器举例说明了Append和RestartStrip函数;它输入三角形,细分和输出了4个细分的三角形:

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

struct VertexOut
{
float3 PosL : POSITION;
float3 NormalL : NORMAL;
float2 Tex : TEXCOORD;
}; struct GeoOut
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION;
float3 NormalW : NORMAL;
float2 Tex : TEXCOORD;
float FogLerp : FOG;
}; void Subdivide(VertexOut inVerts[3], out VertexOut outVerts[6])
{
// 1
// *
// / \
// / \
// m0*-----*m1
// / \ / \
// / \ / \
// *-----*-----*
// 0 m2 2
VertexOut m[3];
// Compute edge midpoints.
m[0].PosL = 0.5f * (inVerts[0].PosL+inVerts[1].PosL);
m[1].PosL = 0.5f * (inVerts[1].PosL+inVerts[2].PosL);
m[2].PosL = 0.5f * (inVerts[2].PosL+inVerts[0].PosL); // Project onto unit sphere
m[0].PosL = normalize(m[0].PosL);
m[1].PosL = normalize(m[1].PosL);
m[2].PosL = normalize(m[2].PosL); // Derive normals.
m[0].NormalL = m[0].PosL;
m[1].NormalL = m[1].PosL;
m[2].NormalL = m[2].PosL; // Interpolate texture coordinates.
m[0].Tex = 0.5f * (inVerts[0].Tex+inVerts[1].Tex);
m[1].Tex = 0.5f * (inVerts[1].Tex+inVerts[2].Tex);
m[2].Tex = 0.5f * (inVerts[2].Tex+inVerts[0].Tex); outVerts[0] = inVerts[0];
outVerts[1] = m[0];
outVerts[2] = m[2];
outVerts[3] = m[1];
outVerts[4] = inVerts[2];
outVerts[5] = inVerts[1];
}; void OutputSubdivision(VertexOut v[6],
inout TriangleStream<GeoOut> triStream)
{
GeoOut gout[6]; [unroll]
for(int i = 0; i < 6; ++i)
{
// Transform to world space space.
gout[i].PosW = mul(float4(v[i].PosL, 1.0f), gWorld).xyz;
gout[i].NormalW = mul(v[i].NormalL, (float3x3)gWorldInvTranspose); // Transform to homogeneous clip space.
gout[i].PosH = mul(float4(v[i].PosL, 1.0f), gWorldViewProj);
gout[i].Tex = v[i].Tex;
} // We can draw the subdivision in two strips:
// Strip 1: bottom three triangles
// Strip 2: top triangle
[unroll]
for(int j = 0; j < 5; ++j)
{
triStream.Append(gout[j]);
} triStream.RestartStrip(); triStream.Append(gout[1]);
triStream.Append(gout[5]);
triStream.Append(gout[3]);
} [maxvertexcount(8)]
void GS(triangle VertexOut gin[3], inout
TriangleStream<GeoOut>)
{
VertexOut v[6];
Subdivide(gin, v);
OutputSubdivision(v, triStream);
}

几何着色器的编译和顶点与像素着色器非常相似,假设在TreeSprite.hlsl文件中由一个GS几何着色器:

mShaders["treeSpriteGS"] = d3dUtil::CompileShader(
L"Shaders\\TreeSprite.hlsl", nullptr, "GS",
"gs_5_0");

然后将它绑定到PSO中:

D3D12_GRAPHICS_PIPELINE_STATE_DESC treeSpritePsoDesc = opaquePsoDesc;

treeSpritePsoDesc.GS =
{
reinterpret_cast<BYTE*> (mShaders["treeSpriteGS"]->GetBufferPointer()),
mShaders["treeSpriteGS"]->GetBufferSize()
};

如果在几何着色器中不输出足够的顶点构成指定的基元,那么这个基元会被废弃。



2 树的公告牌Demo


2.1 概述

如果树木很远,使用公告牌技术可以提高性能,它使用一个2D图片来代替3D树,并且让它一直对准相机。

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

所以给出中心点C,和相机位置E(世界坐标系),我们就可以描述和世界坐标系相关联的公告牌局部坐标系:

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

再给出公告牌的尺寸,公告牌4个顶点就可以计算出来:

v[0] = float4(gin[0].CenterW + halfWidth*right - halfHeight*up, 1.0f);
v[1] = float4(gin[0].CenterW + halfWidth*right + halfHeight*up, 1.0f);
v[2] = float4(gin[0].CenterW - halfWidth*right - halfHeight*up, 1.0f);
v[3] = float4(gin[0].CenterW - halfWidth*right + halfHeight*up, 1.0f);

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

对于当前Demo我们会创建一个顶点基元列表(PrimitiveTopologyType为D3D12_PRIMITIVE_TOPOLOGY_TYPE_POINT的PSO和参数为D3D_PRIMITIVE_TOPOLOGY_POINTLIST的ID3D12GraphicsCommandList::IASetPrimitiveTopology)。这些顶点代表公告牌的中心点。

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

一种常用的公告板实现方法是基于CPU,使用4个顶点代表公告牌放到动态顶点缓冲中(upload heap)。然后当摄像机运动的时候更新顶点,然后memcpyed到GPU缓冲,这样可以保证公告牌面向摄像机。这种方案需要在IA阶段提交4个顶点,并且需要更新动态顶点缓冲(造成性能开销)。但是使用几何着色器,可以使用静态顶点缓冲,并且内存拷贝就很小,我们只需要在IA阶段拷贝1个顶点。


2.2 顶点结构

公告牌点我们使用下面的顶点结构:

struct TreeSpriteVertex
{
XMFLOAT3 Pos;
XMFLOAT2 Size;
}; mTreeSpriteInputLayout =
{
{
"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
},
{
"SIZE", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 12,
D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA, 0
},
};

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)


2.3 HLSL文件

因为这个是我们的第一个几何着色器Demo,所以展示完整的HLSL代码。有一些结构SV_PrimitiveID和Texture2DArray目前没有介绍过,这些会在下面的章节介绍。现在主要关注GS函数,扩展一个顶点到公告牌:

//****************************************************************************
// TreeSprite.hlsl by Frank Luna (C) 2015 All Rights Reserved.
//****************************************************************************
// Defaults for number of lights.
#ifndef NUM_DIR_LIGHTS
#define NUM_DIR_LIGHTS 3
#endif
#ifndef NUM_POINT_LIGHTS
#define NUM_POINT_LIGHTS 0
#endif
#ifndef NUM_SPOT_LIGHTS
#define NUM_SPOT_LIGHTS 0
#endif // Include structures and functions for lighting.
#include "LightingUtil.hlsl" Texture2DArray gTreeMapArray : register(t0);
SamplerState gsamPointWrap : register(s0);
SamplerState gsamPointClamp : register(s1);
SamplerState gsamLinearWrap : register(s2);
SamplerState gsamLinearClamp : register(s3);
SamplerState gsamAnisotropicWrap : register(s4);
SamplerState gsamAnisotropicClamp : register(s5); // Constant data that varies per frame.
cbuffer cbPerObject : register(b0)
{
float4x4 gWorld;
float4x4 gTexTransform;
}; // Constant data that varies per material.
cbuffer cbPass : register(b1)
{
float4x4 gView;
float4x4 gInvView;
float4x4 gProj;
float4x4 gInvProj;
float4x4 gViewProj;
float4x4 gInvViewProj;
float3 gEyePosW;
float cbPerPassPad1;
float2 gRenderTargetSize;
float2 gInvRenderTargetSize;
float gNearZ;
float gFarZ;
float gTotalTime;
float gDeltaTime;
float4 gAmbientLight;
float4 gFogColor;
float gFogStart;
float gFogRange;
float2 cbPerPassPad2; // Indices [0, NUM_DIR_LIGHTS) are directional lights;
// indices [NUM_DIR_LIGHTS, NUM_DIR_LIGHTS+NUM_POINT_LIGHTS) are point
// lights;
// indices [NUM_DIR_LIGHTS+NUM_POINT_LIGHTS,
// NUM_DIR_LIGHTS+NUM_POINT_LIGHT+NUM_SPOT_LIGHTS)
// are spot lights for a maximum of MaxLights per object.
Light gLights[MaxLights];
}; cbuffer cbMaterial : register(b2)
{
float4 gDiffuseAlbedo;
float3 gFresnelR0;
float gRoughness;
float4x4 gMatTransform;
}; struct VertexIn
{
float3 PosW : POSITION;
float2 SizeW : SIZE;
}; struct VertexOut
{
float3 CenterW : POSITION;
float2 SizeW : SIZE;
}; struct GeoOut
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION;
float3 NormalW : NORMAL;
float2 TexC : TEXCOORD;
uint PrimID : SV_PrimitiveID;
}; VertexOut VS(VertexIn vin)
{
VertexOut vout; // Just pass data over to geometry shader.
vout.CenterW = vin.PosW;
vout.SizeW = vin.SizeW; return vout;
} // We expand each point into a quad (4 vertices), so the maximum number of vertices
// we output per geometry shader invocation is 4.
[maxvertexcount(4)]
void GS(point VertexOut gin[1],
uint primID : SV_PrimitiveID,
inout TriangleStream<GeoOut> triStream)
{
//
// Compute the local coordinate system of the sprite relative to the world
// space such that the billboard is aligned with the y-axis and faces the eye.
//
float3 up = float3(0.0f, 1.0f, 0.0f);
float3 look = gEyePosW - gin[0].CenterW;
look.y = 0.0f; // y-axis aligned, so project to xz-plane
look = normalize(look);
float3 right = cross(up, look); //
// Compute triangle strip vertices (quad) in world space.
//
float halfWidth = 0.5f*gin[0].SizeW.x;
float halfHeight = 0.5f*gin[0].SizeW.y; float4 v[4];
v[0] = float4(gin[0].CenterW + halfWidth*right - halfHeight*up, 1.0f);
v[1] = float4(gin[0].CenterW + halfWidth*right + halfHeight*up, 1.0f);
v[2] = float4(gin[0].CenterW - halfWidth*right - halfHeight*up, 1.0f);
v[3] = float4(gin[0].CenterW - halfWidth*right + halfHeight*up, 1.0f); //
// Transform quad vertices to world space and output
// them as a triangle strip.
//
float2 texC[4] =
{
float2(0.0f, 1.0f),
float2(0.0f, 0.0f),
float2(1.0f, 1.0f),
float2(1.0f, 0.0f)
}; GeoOut gout; [unroll]
for(int i = 0; i < 4; ++i)
{
gout.PosH = mul(v[i], gViewProj);
gout.PosW = v[i].xyz;
gout.NormalW = look;
gout.TexC = texC[i];
gout.PrimID = primID;
triStream.Append(gout);
}
} float4 PS(GeoOut pin) : SV_Target
{
float3 uvw = float3(pin.TexC, pin.PrimID%3);
float4 diffuseAlbedo = gTreeMapArray.Sample(gsamAnisotropicWrap, uvw) * gDiffuseAlbedo; #ifdef ALPHA_TEST
// Discard pixel if texture alpha < 0.1. We do this test as soon
// as possible in the shader so that we can potentially exit the
// shader early, thereby skipping the rest of the shader code.
clip(diffuseAlbedo.a - 0.1f);
#endif // Interpolating normal can unnormalize it, so renormalize it.
pin.NormalW = normalize(pin.NormalW); // Vector from point being lit to eye.
float3 toEyeW = gEyePosW - pin.PosW;
float distToEye = length(toEyeW);
toEyeW /= distToEye; // normalize // Light terms.
float4 ambient = gAmbientLight*diffuseAlbedo;
const float shininess = 1.0f - gRoughness;
Material mat = { diffuseAlbedo, gFresnelR0, shininess };
float3 shadowFactor = 1.0f;
float4 directLight = ComputeLighting(gLights, mat, pin.PosW,
pin.NormalW, toEyeW, shadowFactor);
float4 litColor = ambient + directLight; #ifdef FOG
float fogAmount = saturate((distToEye - gFogStart) / gFogRange);
litColor = lerp(litColor, gFogColor, fogAmount);
#endif // Common convention to take alpha from diffuse albedo.
litColor.a = diffuseAlbedo.a; return litColor;
}

2.4 SV_PrimitiveID

在上面的例子中,有一个特殊的无符号整形参数带有SV_PrimitiveID标识:

[maxvertexcount(4)]
void GS(point VertexOut gin[1],
**uint primID : SV_PrimitiveID,**
inout TriangleStream<GeoOut> triStream)

当指定了它的时候,IA阶段会为每个基元自动创建一个基元ID。这个ID只在单个绘制调用中是唯一的。

如果没有几何着色器,SV_PrimitiveID可以添加到顶点着色器的参数列表中。

float4 PS(VertexOut pin, uint primID : SV_PrimitiveID) : SV_Target
{
// Pixel shader body…
}

如果有几何着色器,那么primitive ID只能添加到几何着色器签名中。

IA阶段也可以创建SV_VertexID,通过在顶点着色器签名中添加:

VertexOut VS(VertexIn vin, uint vertID : SV_VertexID)
{
// vertex shader body…
}


3 纹理数组


3.1 概述

纹理数组保存一个数组的纹理,在C++代码中由ID3D12Resource接口表示。创建ID3D12Resource对象的时候,有一个DepthOrArraySize属性定义纹理元素的数量。如果查看Common/DDSTextureLoader.cpp文件中的CreateD3DResources12,你可以看到如何使用它创建纹理数组和体积纹理。在HLSL中,Texture2DArray代表纹理数组:

Texture2DArray gTreeMapArray;

现在你可能会问,为什么要使用纹理数组,为什么不直接这么做:

Texture2D TexArray[4];

float4 PS(GeoOut pin) : SV_Target
{
float4 c = TexArray[pin.PrimID%4].Sample(samLinear, pin.Tex);

在着色器模型5.1中,我们确实可以这么做,但是在前一个D3D版本中,就不能这么做了。并且这样索引纹理会有些硬件上的开销,所以本章使用纹理数组。


3.2 对纹理数组采样

在上面的Demo中,我们这样采样:

float3 uvw = float3(pin.Tex, pin.PrimID%4);
float4 diffuseAlbedo = gTreeMapArray.Sample(gsamAnisotropicWrap, uvw) * gDiffuseAlbedo;

对纹理数组采样的时候,前两个坐标还是代表UV,第三个坐标代表纹理索引。

本章纹理数组资源:

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

纹理数组的另一个好处是,可以在一个绘制调用中对多个基元使用不同纹理进行绘制。而不使用纹理数组,通常情况下,我们只能设置不同的纹理:

SetTextureA();
DrawPrimitivesWithTextureA();
SetTextureB();
DrawPrimitivesWithTextureB();

SetTextureZ();
DrawPrimitivesWithTextureZ();

每一个设置和绘制调用都有开销。而使用纹理数组,我们只需要设置一次:

SetTextureArray();
DrawPrimitivesWithTextureArray();

3.3 加载纹理数组

我们的Common/DDSTextureLoader.h/.cpp支持纹理数组的加载。主要问题是创建具有纹理数组的DDS文件。我们可以使用微软提供的texassemble工具(https://archive.codeplex.com/?p=directxtex)。下面的命令是一个创建的例子:

texassemble -array -o treeArray.dds t0.dds t1.dds t2.dds t2.dds

使用texassemble创建DDS的时候,图像只能有一个Mipmap等级。创建完成后可以使用texconv创建mipmap和修改像素格式(https://archive.codeplex.com/?p=directxtex)。

texconv -m 10 -f BC3_UNORM treeArray.dds

3.4 纹理子资源

下图展示了具有多个纹理的纹理数组的例子:(具有3个纹理,每个纹理有3个mipmap等级)

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

给出一个纹理索引和Mipmap等级,可以访问纹理数组的子资源。然而子资源也可以线性索引:

Introduction to 3D Game Programming with DirectX 12 学习笔记之 --- 第十二章:几何着色器(The Geometry Shader)

下面的函数用来通过给出的mip等级,数组索引和mipmap等级,计算子资源的线性索引:

inline UINT D3D12CalcSubresource( UINT MipSlice,
UINT ArraySlice,
UINT PlaneSlice, UINT MipLevels, UINT ArraySize)
{
return MipSlice + ArraySlice * MipLevels + PlaneSlice * MipLevels * ArraySize;
}


4 ALPHA-TO-COVERAGE

当我们运行树公告牌Demo的时候,树的一些边缘会有块状的错误显示。它是由clip方程导致的,它没有光滑渐变。树的距离越近的时候,这些块状效果就越大。

解决这个的一种方案是使用透明混合替代clip函数的调用,使用线性滤波器让边缘增加一些模糊,输出光滑的过渡而解决块状效果的问题。但是透明混合需要排序和从后往前渲染;如果我们渲染的是一个森林,排序的开销就非常大。并且从后往前渲染的开销也是非常大(第十一章练习8)。

还有一个建议是MSAA(multisampling antialiasing),它是有用的,但是它是基于像素执行,在每个像素的中心,分享颜色到可见的子像素和覆盖。所以主要问题在于,覆盖是在物体等级上决定的。所以MSAA不检测公告牌有alpha通道切割出来的边缘。

当MSAA开启,并且alpha-to-coverage也开启(D3D12_BLEND_DESC::AlphaToCoverageEnable = true),硬件将会根据像素着色器返回的alpha值来决定覆盖([NVIDIA05])。比如4X MSAA,如果像素着色器的alpha维0.5,那么我们可以假设4个子像素中的2个是被覆盖的,所以就会创建平滑的边缘。

对于植物叶子或者围栏,建议一直使用alpha-to-coverage。它需要MSAA是启用的。



5 总结

  1. 假设不使用曲面细分阶段,几何着色器是顶点和像素着色器之间的一个可选的阶段。它会在每个基元上被调用;
  2. 公告牌技术是在一个平面上绘制一个纹理,并将它一直面向摄像机来替代3D物体,提高性能;相对于传统的基于CPU的实现方案,基于几何着色器可以更高效的实现;
  3. SV_PrimitiveID(无符号整形)参数可以添加到几何着色器参数列表中,添加后,在IA阶段中会为每个基元创建一个ID,该ID只在每个绘制调用中是唯一的;
  4. IA阶段也可以创建顶点ID,在顶点着色器参数列表中添加一个SV_VertexID的无符号整形;
  5. 纹理数组保存一个数组的纹理,在C++代码中纹理数组由ID3D12Resource接口表示,通过DepthOrArraySize属性设置纹理个数。在HLSL中由Texture2DArray类型表示,在采样的时候,前两个坐标表示UV,第三个表示索引。
  6. Alpha-to-coverage是硬件根据像素着色器返回的alpha值来决定子像素是否被覆盖,然后来创建平滑的边缘,它是由PAO中D3D12_BLEND_DESC::AlphaToCoverageEnable来控制。


6 练习

练习后续再做

上一篇:php——用for循环打印半金字塔、金字塔、正方形、倒金字塔、菱形、空心图形等


下一篇:漫谈程序员(十八)windows中的命令subst