緊接上篇

未名客:【渲染流程】Cluster_Unity實現概述?

zhuanlan.zhihu.com
圖標

上篇文章,是對Unity 實現Cluster 燈光裁剪的一個概述,從這篇文章開始,我們開始結合代碼詳細展開,實現每一個流程。強烈建議大家先看上篇文章,很多推導,總結都在上篇文章里,這裡及以後的文章不重複相關內容。

用Unity 實現ClusterBasedLighting,一開始考慮的便是Unity 的Srp,不過一來,自己對SRP 不熟,目前也沒有足夠的時間學習相關的東西,其二,本文的重點是梳理Cluster的流程,想更純粹一些。最後受MaxwellGeng 兄弟的啟發,決定,決定從零開始,完全自己手寫。未來某一天如果對SRP 比較熟,會移植一下~

其實這裡所謂的完全自定義,是自己調用一些Unity 較底層的繪製函數,繪製到自己創建的RT上,最後使用一個Blit 操作,把我們自己創建的RT 拷貝到攝像機的RT 上,由Unity 提交,最後在屏幕上顯示。

一、準備環境

這一步比較簡單,首先在一個空場景中,新建腳本Script_ClusterBasedLighting.cs, 並把它掛在Camera 上面,當你的Scene 視圖,背景被清空成灰色,環境準備完成~

clear rt: gray color

[ExecuteInEditMode]
#if UNITY_5_4_OR_NEWER
[ImageEffectAllowedInSceneView]
#endif
public class Script_ClusterBasedLighting : MonoBehaviour
{
private RenderTexture _rtColor;
private RenderTexture _rtDepth;

void Start()
{
_rtColor = new RenderTexture(Screen.width, Screen.height, 24);
_rtDepth = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.Depth, RenderTextureReadWrite.Linear);
}

void OnRenderImage(RenderTexture sourceTexture, RenderTexture destTexture)
{
Graphics.SetRenderTarget(_rtColor.colorBuffer, _rtDepth.depthBuffer);
GL.Clear(true, true, Color.gray);

Graphics.Blit(_rtColor, destTexture);
}
}

我們在_rtColor, _rtDepth 上面繪製內容,最後blit 到 camera 的rt 上。

使用OnRenderImage() 函數,配合【ExecuteInEditMode】,【ImageEffectAllowedInSceneView】 是為了能在Scene視圖窗口 預覽結果。

這部分內容MaxwellGeng 已經解釋的很清楚了,這裡不做贅述,有興趣或者不太明白的小夥伴可以點一下鏈接過去學習一下。

二、預計算ClusterAABB, 繪製調試Cluster

從這一階段開始,我們進入正題,如標題所言,我們將實現如下內容:

  1. 預計算Cluster AABB
  2. 繪製供調試的Cluster

完成以後,就能得到本篇文章標題的畫面啦~

2.1 預計算Cluster AABB

主相機的視錐體被按照一定規則,切分成一定數量的小錐體,為了能更快的完成後面Cluster 與光源的求交,我們用AABB BoundBox 包圍盒這種方式來表示Cluster,且此包圍盒需要完整包含小錐體。這一步,我們的目標就是求出View空間下一系列Cluster AABB 的數組。

之前有提到我們最終實現的cluster, 是類似cube 的aabb,盡量做到均勻分布,實現方法就是在view 空間,做一種類似指數型的切分,具體推導過程見上篇文章,最終我們得到了如下公式:

 k = left[frac{log(-z_{vs} / near)}{log(1 + frac{2tan	heta}{S_y})} 
ight] (1)

 near_k = near left( 1+frac{2tan	heta}{S_y} 
ight)^k (2)

這兩個公式非常重要,是我們後面做各種變化的基礎。簡單解釋一下:

第一個公式:根據View 空間下的z ,計算當前z 所處的 cluster z 方向的Index;

第二個公式:根據cluster z 方向的Index ,反推 View 空間下 z坐標。

設想,假設我們知道屏幕上的一個Tile(即,Cluster 的xy 坐標),配合相機近裁面的z,便可得出一個view 空間下的3d 坐標,這個坐標與 攝像機的位置,便可形成一條線。

同時我知道了view 空間下,第k 個 cluster 的z坐標,根據這個z 構建一個平行於相機近、遠裁面的面,那麼便可以求出 這條線與k 平面的交點。 這個交點其實 就是 小cluster 錐體的某個點啦~

屏幕上的Tile,對應Cluster,及AABB

有了這個思路就可以寫代碼了~

為了方便,我們構建如下結構體:

struct CD_DIM
{
public float fieldOfViewY;
public float zNear;
public float zFar;

public float sD;
public float logDimY;
public float logDepth;

public int clusterDimX;
public int clusterDimY;
public int clusterDimZ;
public int clusterDimXYZ;
};

用來表示cluster 和相機的一些信息。在相機視錐體發生改變時(初始化需要調用一次),計算這個結構:

void CalculateMDim(Camera cam)
{
// The half-angle of the field of view in the Y-direction.
float fieldOfViewY = cam.fieldOfView * Mathf.Deg2Rad * 0.5f;//Degree 2 Radiance: Param.CameraInfo.Property.Perspective.fFovAngleY * 0.5f;
float zNear = cam.nearClipPlane;// Param.CameraInfo.Property.Perspective.fMinVisibleDistance;
float zFar = cam.farClipPlane;// Param.CameraInfo.Property.Perspective.fMaxVisibleDistance;

// Number of clusters in the screen X direction.
int clusterDimX = Mathf.CeilToInt(Screen.width / (float)m_ClusterGridBlockSize);
// Number of clusters in the screen Y direction.
int clusterDimY = Mathf.CeilToInt(Screen.height / (float)m_ClusterGridBlockSize);

// The depth of the cluster grid during clustered rendering is dependent on the
// number of clusters subdivisions in the screen Y direction.
// Source: Clustered Deferred and Forward Shading (2012) (Ola Olsson, Markus Billeter, Ulf Assarsson).
float sD = 2.0f * Mathf.Tan(fieldOfViewY) / (float)clusterDimY;
float logDimY = 1.0f / Mathf.Log(1.0f + sD);

float logDepth = Mathf.Log(zFar / zNear);
int clusterDimZ = Mathf.FloorToInt(logDepth * logDimY);

m_DimData.zNear = zNear;
m_DimData.zFar = zFar;
m_DimData.sD = sD;
m_DimData.fieldOfViewY = fieldOfViewY;
m_DimData.logDepth = logDepth;
m_DimData.logDimY = logDimY;
m_DimData.clusterDimX = clusterDimX;
m_DimData.clusterDimY = clusterDimY;
m_DimData.clusterDimZ = clusterDimZ;
m_DimData.clusterDimXYZ = clusterDimX * clusterDimY * clusterDimZ;
}

其中,clusterDimX/Y/Z 就是Cluster 的三維個數, clusterDimXYZ 就是總個數。

我們根據這個clusterDimXYZ,來創建一個AABB數組

private ComputeBuffer cb_ClusterAABBs;

void Start()
{
//...
CalculateMDim(_camera);

int stride = Marshal.SizeOf(typeof(AABB));
cb_ClusterAABBs = new ComputeBuffer(m_DimData.clusterDimXYZ, stride);
//...
}

完成以後,就可以調用CS 數組裡面填數據了。

2.1.1 先說shader

CS 計算的過程,就是我們剛剛分析的過程,完整的cs代碼,如下:

// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel CSMain
#pragma enable_d3d11_debug_symbols

//Cluster Data
uint3 ClusterCB_GridDim; // The 3D dimensions of the cluster grid.
float ClusterCB_ViewNear; // The distance to the near clipping plane. (Used for computing the index in the cluster grid)
uint2 ClusterCB_Size; // The size of a cluster in screen space (pixels).
float ClusterCB_NearK; // ( 1 + ( 2 * tan( fov * 0.5 ) / ClusterGridDim.y ) ) // Used to compute the near plane for clusters at depth k.
float ClusterCB_LogGridDimY; // 1.0f / log( 1 + ( tan( fov * 0.5 ) / ClusterGridDim.y )
float4 ClusterCB_ScreenDimensions;

struct Plane
{
float3 N; // Plane normal.
float d; // Distance to origin.
};

/**
* Convert a 1D cluster index into a 3D cluster index.
*/
uint3 ComputeClusterIndex3D(uint clusterIndex1D)
{
uint i = clusterIndex1D % ClusterCB_GridDim.x;
uint j = clusterIndex1D % (ClusterCB_GridDim.x * ClusterCB_GridDim.y) / ClusterCB_GridDim.x;
uint k = clusterIndex1D / (ClusterCB_GridDim.x * ClusterCB_GridDim.y);

return uint3(i, j, k);
}

/**
* Convert the 3D cluster index into a 1D cluster index.
*/
uint ComputeClusterIndex1D(uint3 clusterIndex3D)
{
return clusterIndex3D.x + (ClusterCB_GridDim.x * (clusterIndex3D.y + ClusterCB_GridDim.y * clusterIndex3D.z));
}

/**
* Compute the 3D cluster index from a 2D screen position and Z depth in view space.
* source: Clustered deferred and forward shading (Olsson, Billeter, Assarsson, 2012)
*/
uint3 ComputeClusterIndex3D(float2 screenPos, float viewZ)
{
uint i = screenPos.x / ClusterCB_Size.x;
uint j = screenPos.y / ClusterCB_Size.y;
// It is assumed that view space z is negative (right-handed coordinate system)
// so the view-space z coordinate needs to be negated to make it positive.
uint k = log(viewZ / ClusterCB_ViewNear) * ClusterCB_LogGridDimY;

return uint3(i, j, k);
}
/**
* Find the intersection of a line segment with a plane.
* This function will return true if an intersection point
* was found or false if no intersection could be found.
* Source: Real-time collision detection, Christer Ericson (2005)
*/
bool IntersectLinePlane(float3 a, float3 b, Plane p, out float3 q)
{
float3 ab = b - a;

float t = (p.d - dot(p.N, a)) / dot(p.N, ab);

bool intersect = (t >= 0.0f && t <= 1.0f);

q = float3(0, 0, 0);
if (intersect)
{
q = a + t * ab;
}

return intersect;
}

/// Functions.hlsli
// Convert clip space coordinates to view space
float4 ClipToView(float4 clip)
{
// View space position.
//float4 view = mul(clip, g_Com.Camera.CameraProjectInv);
float4 view = mul(_InverseProjectionMatrix, clip);
// Perspecitive projection.
view = view / view.w;

return view;
}

// Convert screen space coordinates to view space.
float4 ScreenToView(float4 screen)
{
// Convert to normalized texture coordinates in the range [0 .. 1].
float2 texCoord = screen.xy * ClusterCB_ScreenDimensions.zw;

// Convert to clip space
float4 clip = float4(texCoord * 2.0f - 1.0f, screen.z, screen.w);

return ClipToView(clip);
}

#ifndef BLOCK_SIZE
#define BLOCK_SIZE 1024
#endif

struct ComputeShaderInput
{
uint3 GroupID : SV_GroupID; // 3D index of the thread group in the dispatch.
uint3 GroupThreadID : SV_GroupThreadID; // 3D index of local thread ID in a thread group.
uint3 DispatchThreadID : SV_DispatchThreadID; // 3D index of global thread ID in the dispatch.
uint GroupIndex : SV_GroupIndex; // Flattened local index of the thread within a thread group.
};

struct AABB
{
float4 Min;
float4 Max;
};
RWStructuredBuffer<AABB> RWClusterAABBs;

[numthreads(BLOCK_SIZE, 1, 1)]
void CSMain(ComputeShaderInput cs_IDs)
{
uint clusterIndex1D = cs_IDs.DispatchThreadID.x;

// Convert the 1D cluster index into a 3D index in the cluster grid.
uint3 clusterIndex3D = ComputeClusterIndex3D(clusterIndex1D);

// Compute the near and far planes for cluster K.
Plane nearPlane = { 0.0f, 0.0f, 1.0f, ClusterCB_ViewNear * pow(abs(ClusterCB_NearK), clusterIndex3D.z) };
Plane farPlane = { 0.0f, 0.0f, 1.0f, ClusterCB_ViewNear * pow(abs(ClusterCB_NearK), clusterIndex3D.z + 1) };

// The top-left point of cluster K in screen space.
float4 pMin = float4(clusterIndex3D.xy * ClusterCB_Size.xy, 0.0f, 1.0f);
// The bottom-right point of cluster K in screen space.
float4 pMax = float4((clusterIndex3D.xy + 1) * ClusterCB_Size.xy, 0.0f, 1.0f);

// Transform the screen space points to view space.
pMin = ScreenToView(pMin);
pMax = ScreenToView(pMax);

pMin.z *= -1;
pMax.z *= -1;

// Find the min and max points on the near and far planes.
float3 nearMin, nearMax, farMin, farMax;
// Origin (camera eye position)
float3 eye = float3(0, 0, 0);
IntersectLinePlane(eye, (float3)pMin, nearPlane, nearMin);
IntersectLinePlane(eye, (float3)pMax, nearPlane, nearMax);
IntersectLinePlane(eye, (float3)pMin, farPlane, farMin);
IntersectLinePlane(eye, (float3)pMax, farPlane, farMax);

float3 aabbMin = min(nearMin, min(nearMax, min(farMin, farMax)));
float3 aabbMax = max(nearMin, max(nearMax, max(farMin, farMax)));

AABB aabb = { float4(aabbMin, 1.0f), float4(aabbMax, 1.0f) };

RWClusterAABBs[clusterIndex1D] = aabb;
}

ComputeShader 分析:

1、每一個cluster 就是一個線程,若一個線程組有1024個線程,所以,需要dispath m_DimData.clusterDimXYZ / 1024.0 個線程組

2、為了方便填充我們的一維數組結果,這裡我們分配的線程組是一維的。雖然cluster index 是3維的,但是不虛,因為index 3維到一維是非常方便的。所以在shader 的一開始,我們寫了如下一些用於轉換的工具函數,這些函數後面會被反覆用到。

/**
* Convert a 1D cluster index into a 3D cluster index.
*/
uint3 ComputeClusterIndex3D(uint clusterIndex1D)
{
uint i = clusterIndex1D % ClusterCB_GridDim.x;
uint j = clusterIndex1D % (ClusterCB_GridDim.x * ClusterCB_GridDim.y) / ClusterCB_GridDim.x;
uint k = clusterIndex1D / (ClusterCB_GridDim.x * ClusterCB_GridDim.y);

return uint3(i, j, k);
}

/**
* Convert the 3D cluster index into a 1D cluster index.
*/
uint ComputeClusterIndex1D(uint3 clusterIndex3D)
{
return clusterIndex3D.x + (ClusterCB_GridDim.x * (clusterIndex3D.y + ClusterCB_GridDim.y * clusterIndex3D.z));
}

/**
* Compute the 3D cluster index from a 2D screen position and Z depth in view space.
* source: Clustered deferred and forward shading (Olsson, Billeter, Assarsson, 2012)
*/
uint3 ComputeClusterIndex3D(float2 screenPos, float viewZ)
{
uint i = screenPos.x / ClusterCB_Size.x;
uint j = screenPos.y / ClusterCB_Size.y;
uint k = log(viewZ / ClusterCB_ViewNear) * ClusterCB_LogGridDimY;

return uint3(i, j, k);
}

前兩個轉換非常簡單,第三個有一點意思,用到了上面提到的公式1, 即,根據屏幕像素坐標,和 view 空間的z 計算cluster 的3維index。

3、接下來是一個線面求交的函數,這個等下將用來計算 cluster 錐體兩個平面的的左上角,和右下角,四個頂點。

線面求交,簡單圖示

4、再下來就是常規的空間轉化,不多解釋。

5、CSMain 的內容簡單介紹:

1)DispatchThreadID 即對應cluster 的1維索引。

2)這裡要注意,Unity 的View 空間是右手坐標系,我們所有view 空間下的z 值都是負數,同理,在構建縱向k,k+1 兩個平面的時候,他們的法向量是,( 0,0,1)。

3)下面的代碼是,計算view 空間的z, 用到了公式1

ClusterCB_ViewNear * pow(abs(ClusterCB_NearK), clusterIndex3D.z)

4)我們將屏幕tile (即cluster 的xy) 左上角,右下角轉換到view空間,求出view 空間的坐標,pMin, pMax。注意view z 是負數。

5)最後就可以做線面求交了,從四個點中,拼出最小最大的,就是我們cluster aabb 的左上,右下角的點啦~

cluster AABB 的計算,到了這一步,就已經完成了。在實際遊戲運行過程中,因為視錐體常規情況下是不會發生變化的,所以可以預先把數據準備好,在Init 的時候就可以Dispatch 執行CS了。

2.1.2 c# 分析

最後簡單看一眼c# 這邊情況

void UpdateClusterCBuffer(ComputeShader cs)
{
int[] gridDims = { m_DimData.clusterDimX, m_DimData.clusterDimY, m_DimData.clusterDimZ };
int[] sizes = { m_ClusterGridBlockSize, m_ClusterGridBlockSize };
Vector4 screenDim = new Vector4((float)Screen.width, (float)Screen.height, 1.0f / Screen.width, 1.0f / Screen.height);
float viewNear = m_DimData.zNear;

cs.SetInts("ClusterCB_GridDim", gridDims);
cs.SetFloat("ClusterCB_ViewNear", viewNear);
cs.SetInts("ClusterCB_Size", sizes);
cs.SetFloat("ClusterCB_NearK", 1.0f + m_DimData.sD);
cs.SetFloat("ClusterCB_LogGridDimY", m_DimData.logDimY);
cs.SetVector("ClusterCB_ScreenDimensions", screenDim);
}
void Pass_ComputeClusterAABB()
{
var projectionMatrix = GL.GetGPUProjectionMatrix(_camera.projectionMatrix, false);
var projectionMatrixInvers = projectionMatrix.inverse;
cs_ComputeClusterAABB.SetMatrix("_InverseProjectionMatrix", projectionMatrixInvers);

UpdateClusterCBuffer(cs_ComputeClusterAABB);

int threadGroups = Mathf.CeilToInt(m_DimData.clusterDimXYZ / 1024.0f);

int kernel = cs_ComputeClusterAABB.FindKernel("CSMain");
cs_ComputeClusterAABB.SetBuffer(kernel, "RWClusterAABBs", cb_ClusterAABBs);
cs_ComputeClusterAABB.Dispatch(kernel, threadGroups, 1, 1);
}

這裡有一點要注意的是,Unity InvProjectMatrix 的用法。Dx 系列和OpenGl 系類的投影是不一樣的。DX是以左上角為(0,0) OpenGL是以右下角為(0,0) ,DX的Z範圍是(0,1) GL是(-1,1)。為了跨平台,Unity 使用GetGPUProjectionMatrix 函數,做轉換,友情提示,這裡處理不好,出來的結果很有可能是 Y 方向相反哦。

這個我當時也被坑了,最後翻了翻Untiy PostProcess Steck 裡面SSR 代碼,才明白過來.....

2.2 繪製供調試的Cluster

ClusterAABB 計算完以後,心裡還是沒底,也為了後面調試的方便,我們首先把計算出來的AABB 畫出來,先看個大概~

cluster AABB 的調試界面

繪製的話,我們使用GS的方式,提交clusterDimXYZ 這個點,shader 裡面根據點的id, 訪問我們剛剛完成的數組,就能拿到當前點對應的cluster aabb 啦,然後在GS裡面,生成8個點,表示cube。

簡單看一下shader :

Shader "ClusterBasedLightingGit/Shader_DebugCluster"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100

Pass
{
CGPROGRAM

#pragma vertex main_VS
#pragma fragment main_PS
#pragma geometry main_GS
#pragma target 5.0
#pragma enable_d3d11_debug_symbols

#include "UnityCG.cginc"

struct VertexShaderOutput
{
float4 Min : AABB_MIN; // Min vertex position in view space.
float4 Max : AABB_MAX; // Max vertex position in view space.
float4 Color : COLOR; // Cluster color.
};

struct GeometryShaderOutput
{
float4 Color : COLOR;
float4 Position : SV_POSITION; // Clip space position.
};

struct AABB
{
float4 Min;
float4 Max;
};

StructuredBuffer<AABB> ClusterAABBs;// : register(t1);

bool CMin(float3 a, float3 b)
{
if (a.x < b.x && a.y < b.y && a.z < b.z)
return true;
else
return false;
}

bool CMax(float3 a, float3 b)
{
if (a.x > b.x && a.y > b.y && a.z > b.z)
{
return true;
}
else
{
return false;
}
}

float4 WorldToProject(float4 posWorld)
{
float4 posVP0 = UnityObjectToClipPos(posWorld);
return posVP0;
}

VertexShaderOutput main_VS(uint VertexID : SV_VertexID)
{
uint clusterID = VertexID; ;// UniqueClusters[VertexID];// VertexID;

VertexShaderOutput vsOutput = (VertexShaderOutput)0;

AABB aabb = ClusterAABBs[clusterID];// ClusterAABBs[VertexID];

vsOutput.Min = aabb.Min;
vsOutput.Max = aabb.Max;

float4 factor = aabb.Max - aabb.Min;
//factor *= 0.2;
vsOutput.Max = aabb.Min + factor;
vsOutput.Color = float4(1,1,1,1);

return vsOutput;
}

// Geometry shader to convert AABB to cube.
[maxvertexcount(16)]
void main_GS(point VertexShaderOutput IN[1], inout TriangleStream<GeometryShaderOutput> OutputStream)
{
float4 min = IN[0].Min;
float4 max = IN[0].Max;

// Clip space position
GeometryShaderOutput OUT = (GeometryShaderOutput)0;

// AABB vertices
const float4 Pos[8] = {
float4(min.x, min.y, min.z, 1.0f), // 0
float4(min.x, min.y, max.z, 1.0f), // 1
float4(min.x, max.y, min.z, 1.0f), // 2

float4(min.x, max.y, max.z, 1.0f), // 3
float4(max.x, min.y, min.z, 1.0f), // 4
float4(max.x, min.y, max.z, 1.0f), // 5
float4(max.x, max.y, min.z, 1.0f), // 6
float4(max.x, max.y, max.z, 1.0f) // 7
};

// Colors (to test correctness of AABB vertices)
const float4 Col[8] = {
float4(0.0f, 0.0f, 0.0f, 1.0f), // Black
float4(0.0f, 0.0f, 1.0f, 1.0f), // Blue
float4(0.0f, 1.0f, 0.0f, 1.0f), // Green
float4(0.0f, 1.0f, 1.0f, 1.0f), // Cyan
float4(1.0f, 0.0f, 0.0f, 1.0f), // Red
float4(1.0f, 0.0f, 1.0f, 1.0f), // Magenta
float4(1.0f, 1.0f, 0.0f, 1.0f), // Yellow
float4(1.01, 1.0f, 1.0f, 1.0f) // White
};

const uint Index[18] = {
0, 1, 2,
3, 6, 7,
4, 5, -1,
2, 6, 0,
4, 1, 5,
3, 7, -1
};

[unroll]
for (uint i = 0; i < 18; ++i)
{
if (Index[i] == (uint) - 1)
{
OutputStream.RestartStrip();
}
else
{
OUT.Position = WorldToProject(Pos[Index[i]]);
OUT.Color = IN[0].Color;
OutputStream.Append(OUT);
}
}
}

float4 main_PS(GeometryShaderOutput IN) : SV_Target
{
return IN.Color;
}

ENDCG
}
}
}

GS 的用法和DX11, 很常規,這裡就不多介紹了,不了解的小夥伴,可以搜一下,GS的具體用法,功能很靈活,但是慎用哦,效率是一個很大的問題。

可以從上面的圖裡看出,Cluster 的AABB 確實把小錐體完全包圍住了, 因為很多AABB在邊緣的地方都有重疊,這是符合我們的需求的。為了方便查看,我把aabb 的左上角做了保留,實際大小做了一下縮放,看上去,清晰明了~ 標題圖,get!

c# 腳本簡單看一眼

void Pass_DebugCluster()
{
GL.wireframe = true;

mtlDebugCluster.SetBuffer("ClusterAABBs", cb_ClusterAABBs);

mtlDebugCluster.SetPass(0);
Graphics.DrawProceduralNow(MeshTopology.Points, m_DimData.clusterDimXYZ);

GL.wireframe = false;
}
void OnRenderImage(RenderTexture sourceTexture, RenderTexture destTexture)
{
Graphics.SetRenderTarget(_rtColor.colorBuffer, _rtDepth.depthBuffer);
GL.Clear(true, true, Color.gray);

Pass_DebugCluster();

Graphics.Blit(_rtColor, destTexture);
}

沒什麼好說的=-=

因為白天還要上班,晚上回家抽了一點時間寫了這麼多,回過神兒來,不知不覺,已經凌晨0.27分了,由於時間問題,就只能先總結到了,下一篇再見~


推薦閱讀:
相关文章