本人入門圖形學不久,希望能在接下來的學習中有一個自己的playground,方便自己做一些小實驗以及方便以後畢業找工作,於是最近我終於下定決心開始造一個屬於自己的小輪子,不求把功能做到很完善,但求粗略地了解一下架構,以及儘可能熟悉其中一小部分系統,順便把過程寫在這裡,算是記錄一下自己的成長過程吧。(題圖會不會血腥了點233333)
github地址(目前渲染部分代碼亂七八糟,之後要重構一次):
圖形庫用的是DirectX12,界面是WPF和Winform,目前引擎已經完成了基本的界面瀏覽功能、延遲管線、模型載入以及PBR,效果圖如下(效果目前有點丟人- -)
PBR這個東西,真說要講可能也輪不到我這個萌新來講,因此這篇文章只是我個人對PBR的一些簡單的理解和複述,也就是說會盡量地「講人話」,在這個基礎上,最好結合一些專業的資料來看,達到更好的理解效果。本人才疏學淺,如果有什麼錯誤請指出。
首先關於PBR的理論,推薦這篇
可以看這個目錄下的跟PBR有關的這四篇
以及淺墨的這個專欄,裡面有一系列PBR相關的內容
上面那四篇是講的最清楚的,如果認真看完基本上就能把程序寫出來了,裡面是給了代碼的,雖然是opengl但是翻譯成dx也不難。
那麼接下來就可以開始談PBR了,PBR就是一個光照模型,我這個引擎里實現的是最簡單的PBR,也就是和虛幻里的做法基本上是一模一樣的,我們可以回憶一下,經典的光照模型裡面,漫反射是Lambert,鏡面反射是Blinn-Phong,環境光是c*(1-AO),而PBR,則是換了個更好看的模型,漫反射依然是Lambert(不過多除了個Pi),鏡面反射是Cook-Torrance,環境光是IBL(Image-Based Lighting)。
接下來就要不可避免地談到反射方程了
Lo就是出射光強, 是入射的立體角, 是出射的立體角,p是頂點位置,Li點乘法線n,這個可以理解成入射的光強乘以蘭伯特餘弦,那麼剩下的 ,就是我們待會要討論的主角,表示在p點這個位置,入射方向到出射方向光的反射比例。
接下來我們重新審視一遍這個式子,光線從各個方向入射,入射的能量會均勻分攤到照射到的面積上,所以要乘一個蘭伯特餘弦,也就是點乘一個法線,然後剩下的入射光強乘以入射和出射的比例,得到的其實就是這個光貢獻的出射的光強,然後把所有方向來的光貢獻的光強積分,就得到了最後的光強,這個積分是在p點法線為中心的半球上進行的。
那麼問題來了,這個出射光和入射光的比例 是多少?這個函數就是BxDF,在我們實現的這個PBR模型裡面,用的是BRDF(bi-directional reflectance distribution function,雙向反射分布函數),如下
這個式子可以這樣理解,反射光分為兩部分,一部分是漫反射,一部分是鏡面反射,剛剛說過lambert就是漫反射部分,cook-torrance就是鏡面反射部分,那環境光呢?環境光後面會提,這裡我們先關注漫反射和高光這兩個部分。
就是蘭伯特反射分布函數, 是高光部分的反射分布,然後有 ,這裡 應該理解成漫反射(d)的佔比,而相應的 就是鏡面反射(s)的佔比,也就是說如果我們要把反射的光拆成diffuse和specular這兩部分,我們要保證能量守恆。(其實這個 是包含在Cook-Torrance裡面的,沒必要寫出來,但是為了強調能量守恆,還是放在上面了)(然後 一般也還要乘個(1-metal),為了方便理解,先不要在意這個)
再說BRDF,BRDF就是出射與入射的比例,也就是 (注意單位),除了BRDF以外,還有其他的模型,如BSDF、BTDF、BSSRDF等,而遊戲中用的最多的是BRDF,也就是只考慮反射,接下來我們可以具體看這個BRDF的內容。
漫反射是蘭伯特,式子很簡單,如下
蘭伯特的反照率就是一個常量,也就是albedo貼圖上採樣得到的值,但是和以前的蘭伯特比,PBR里多除了一個 ,為什麼要除呢,其實這個地方除個 是保證能量守恆,因為我們的c是從貼圖裡面採樣出來的,那麼範圍是在0到1之間的,但是實際上要能量守恆,c不能夠大於 ,證明如下
所以這裡除個 ,就是把[0, 1]範圍歸一化到[0, ],以前的不除的做法,是無法保證能量守恆的。這樣是保證了全部的漫反射不會超過總能量,實際上還乘了個kd,則是保證了漫反射和鏡面反射不會超過總能量。
PBR的鏡面反射是Cook-Torrance,這部分內容是重頭戲,先看結果
分子部分
分子包含了三個部分,D、F、G,首先看D。
D是Normal Distribution Function,簡稱NDF,中文應該叫法線分布函數(注意上面那篇的中文翻譯里翻譯成了正態分布函數,這是不合適的),如下
這個式子又稱為Trowbridge-Reitz GGX,描述的是法線關於半形向量h和法線n的分布,其中 是描述粗糙度的量,具體的可以根據實際情況來選擇,虛幻的是 (好像之前看到frostbite是用的粗糙度四次方,有待確認),我這裡用的是和虛幻一樣的。
需要注意的一點是,這個NDF不是概率密度函數,這個式子的歸一是要乘上微平面法線m的 的,這裡不展開講了,後面還會提到。
然後第二個部分F,這個部分就是我們熟悉的菲涅爾了,用的依然是Schlick近似,如下
菲涅爾描述的是,光的入射角和法線夾角小的時候,反射率小,而夾角大的時候,反射率大,也就是說,菲涅爾描述的其實是鏡面反射光的佔比,也就是上面提到的 ,是包含在這個BRDF里的,不需要單獨寫出來。
然後我這裡用的不是這個版本,而是Epic提出的擬合版本
這個的好處是稍微比算power快點,不過也就是說得用exp2()這個函數來算,不然應該沒啥意義。 具體出處可以看這裡
最後就是幾何部分G,這裡要先提到Schlick GGX,如下
這部分的物理含義是,光線被崎嶇不平的物體表面遮擋後,所剩下來的量,如圖
其中的k應該這樣計算
這其中的 是粗糙度。我們現在在算直接光部分,所以就用 。
然後從圖上可以看出,光線入射的時候有一次遮蔽,出射到眼睛的時候還有一次遮蔽,這兩次分別跟入射矢量和出射矢量有關,所以最終的幾何部分是
一次是用v(view)算一次使用l(light)算。
這樣,分子里DFG三個部分就已經講完了,我們可以這樣理解,光入射後,根據F知道有多少光被鏡面反射了,然後乘上D,即有多少成分反射到了視角的方向,然後乘上G,也就是被遮擋了多少,就是出射光強,不過這只是分子,我們還沒考慮分母,我們要像剛才漫反射那樣,保證能量守恆,也就是說純鏡面反射的能量不能超過入射的總能量,所以還要除掉一個分母。
配平的這部分,大部份講PBR的文章里都沒有給出具體解釋,給我當時的學習也造成了困難,所以為了區別於其他講PBR的文章,我會把我找到的解釋寫在這裡。
可以參考
為了歸一化,我們首先要找出能量是怎麼守恆的,這就需要先了解一下微平面理論,上面的資料里也都提了,這裡我們只考慮一個純鏡面反射的情況。這種情況下我們的BRDF是 ,k就是我們要找的係數,然後接下來要看一張圖。(這張圖是從某盜版書里拍下來的,,正版太貴實在買不起)
看右邊這張,這張圖告訴我們,微平面中只有一些部分會對視角,也就是出射立體角接受的光線做出貢獻,圖上正的部分和負的部分會抵消,所以實際上宏觀平面在出射立體角上的投影和微平面在出射立體角上的投影是相等的,都是總面積乘上一個 。
然後接下來就可以列出能量守恆的式子了,我們有
BRDF是出射和入射的比值(先別去追究單位),那麼各方向出射的能量加起來和入射能量的比值為1,就是守恆。
接下來會有巨大多計算,懶得打公式和畫圖,我就用手寫了
這樣一來鏡面反射部分也保證能量守恆了。
現在我們再來看一次反射方程,用上面的式子來展開,有
那麼現在這個式子的含義就已經基本上可以理解了,注意右邊鏡面反射部分把 拿掉了,因為這裡F就是 了。
接下來是PBR里的環境光,用的是IBL,也就是基於圖像的光照,其實一般的很老的圖形學基礎入門書里也都會提到用cubemap的採樣和f0來渲染對環境的反射,那PBR的做法與傳統有什麼不同呢?答案自然是PBR的做法會更加的物理。事實上我們不太可能在遊戲里事實地對物體周遭進行採樣然後實時計算積分,這個代價太大了,即使是不物理的,也就是擺個cubemap採樣攝像頭,然後不積分,雖然可以實時跑,但是代價也大得可怕,所以其實現在的做法依然是搞一張固定的環境貼圖,事先算好,然後實時跑地時候只做小量的運算。
首先環境光還是老樣子,拆成兩部分,漫反射和鏡面反射
和經典的一個常量c乘上1-AO不同,這裡我們不再是用常量來表示環境光的輻射度,而是真正真正的把它算出來,但是,問題來了,這個難度是非常大的,和剛才的直接光計算不同,這次是要真正的積分了。
剛才的直接光,我們都是用加的,因為無論是點光源,還是平行光,都只從一個方向來,這種情況下積分就是做加法,但是環境光就不同了,我們從整個環境(也就是一張cubemap)上面獲得光照信息,可以認為這個光源是個面光源,是有面積的,所以環境光的計算就是把上面的式子積分算出來。
漫反射部分如下
原理和直接光一模一樣,只是要把整個環境cubemap積一次分,實際上我們積的是以法線n為中心的半球,這裡好像也沒什麼好說的,直接算就是了,如下
右邊是積分結果,看起來像是一張模糊過了的環境貼圖,可以理解。但是我們不能用高斯模糊來代替這個積分運算,因為我們做這件事的意義就是讓光照更加物理,那麼追求物理上的正確是理所應當的。
這個積分過程我也看了一些別人的實現,似乎都是等間距採樣,為什麼不蒙特卡洛呢,我猜可能是為了加快收斂速度吧,我最後寫的也是等間距採樣,但是用蒙特卡洛積分也是一樣的, 總之看到結果比較平滑,不是很噪了就可以了。
鏡面反射相比之下就比較複雜了,因為運算量會遠遠大於漫反射,如下
這個過程跟剛才的有點區別,我們還要考慮粗糙度,但是這樣的話就太複雜了,所以一般我們只採樣個五六張,每一張對應不同粗糙度,然後三線性插值得到最終採樣結果(是的,這樣就不物理了)。然後我在實現的時候用了虛幻提出的一個近似演算法,Split Sum,把這個積分拆成兩個部分
現在的非常多做法都是這樣做的,首先看左邊部分,叫Pre-Filtered Environment Map,計算不同粗糙度如下。
然後這個部分的積分,我看很多地方給的實現,依然不是用標準的蒙特卡洛,而是用的准蒙特卡洛演算法,有什麼區別呢,這種做法是產生一系列不那麼隨機的、分布還算比較均勻的假的隨機數,來加快收斂速度
這就是蒙特卡洛常用的偽隨機序列和偽蒙特卡洛的low-discrepancy序列的區別,可以想像右邊的收斂會快很多。 (具體代碼會貼在後面。)
然後剩下右邊部分,
這一部分是定死的,可以預先算好,算出來之後得到這樣一張貼圖
這個就是虛幻著名的look-up texture(LUT),這張圖我就沒有親手去算了,到處都能找到,就直接嫖了。
這張圖用法是用roughness和NdotV去採樣,然後積分結果等於(F * envBRDF.x + envBRDF.y),具體可以看代碼。
其實上面給的四篇參考里已經有非常完整的實現了,使用opengl寫的,這裡我貼一下我用dx12寫的代碼以便參考
首先是事前準備,算好環境貼圖:
Irradiance預積分
static const float PI = 3.14159265359;
TextureCube gCubeMap : register(t0); SamplerState basicSampler : register(s0);
cbuffer cbPerObject : register(b0) { float4x4 gWorld; float4x4 gTexTransform; uint gMaterialIndex; uint gObjPad0; uint gObjPad1; uint gObjPad2; };
cbuffer cbPass : register(b1) { float4x4 gViewProj; float3 gEyePosW; float roughnessCb; };
struct VertexOut { float4 PosH : SV_POSITION; float3 PosL : POSITION; };
float4 main(VertexOut pin) : SV_TARGET { float3 irradiance = float3(0.0f, 0.0f, 0.0f);
float3 normal = normalize(pin.PosL); float3 up = float3(0.0, 1.0, 0.0); float3 right = cross(up, normal); up = cross(normal, right);
float sampleDelta = 0.025f; float numSamples = 0.0f; for (float phi = 0.0f; phi < 2.0f * PI; phi += sampleDelta) { for (float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta) { // spherical to cartesian (in tangent space) float3 tangentSample = float3(sin(theta) * cos(phi), sin(theta) * sin(phi), cos(theta)); // tangent space to world float3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * normal;
irradiance += gCubeMap.Sample(basicSampler, sampleVec).rgb * cos(theta) * sin(theta); numSamples++; } } irradiance = PI * irradiance * (1.0f / numSamples);
return float4(irradiance, 1.0f); }
Prefilter Map積分
float RadicalInverse_VdC(uint bits) { bits = (bits << 16u) | (bits >> 16u); bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u); bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u); bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u); bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u); return float(bits) * 2.3283064365386963e-10; // / 0x100000000 }
float2 Hammersley(uint i, uint N) { return float2(float(i) / float(N), RadicalInverse_VdC(i)); }
float3 ImportanceSampleGGX(float2 Xi, float3 N, float roughness) { float a = roughness * roughness;
float phi = 2.0 * PI * Xi.x; float cosTheta = sqrt((1.0 - Xi.y) / (1.0 + (a*a - 1.0) * Xi.y)); float sinTheta = sqrt(1.0 - cosTheta * cosTheta);
// from spherical coordinates to cartesian coordinates float3 H; H.x = cos(phi) * sinTheta; H.y = sin(phi) * sinTheta; H.z = cosTheta;
// from tangent-space vector to world-space sample vector float3 up = abs(N.z) < 0.999 ? float3(0.0, 0.0, 1.0) : float3(1.0, 0.0, 0.0); float3 tangent = normalize(cross(up, N)); float3 bitangent = cross(N, tangent);
float3 sampleVec = tangent * H.x + bitangent * H.y + N * H.z; return normalize(sampleVec); }
float4 main(VertexOut pin) : SV_TARGET { float roughness = roughnessCb; const uint NumSamples = 1024u; float3 N = normalize(pin.PosL); float3 R = N; float3 V = R;
float3 prefilteredColor = float3(0.f, 0.f, 0.f); float totalWeight = 0.f; for (uint i = 0u; i < NumSamples; ++i) { float2 Xi = Hammersley(i, NumSamples); float3 H = ImportanceSampleGGX(Xi, N, roughness); float3 L = normalize(2.0f * dot(V, H) * H - V); float NdotL = max(dot(N, L), 0.0f); if (NdotL > 0.f) { prefilteredColor += gCubeMap.Sample(basicSampler, L).rgb * NdotL; totalWeight += NdotL; } }
prefilteredColor = prefilteredColor / totalWeight;
return float4(prefilteredColor, 1.0f); }
然後是光照計算
#ifndef _LIGHTING_HLSLI #define _LIGHTING_HLSLI #define MAX_DIRECTIONAL_LIGHT_NUM 4 #define MAX_POINT_LIGHT_NUM 16 #define MAX_SPOTLIGHT_NUM 16 static const float MIN_ROUGHNESS = 0.0000001f; static const float F0_NON_METAL = 0.04f; static const float PI = 3.14159265359f;
struct SpotLight { float4 Color; float4 Direction; float3 Position; float Range; float SpotlightAngle; };
struct DirectionalLight { float4 AmbientColor; float4 DiffuseColor; float3 Direction; float Intensity; };
struct PointLight { float4 Color; float3 Position; float Range; float Intensity; float3 Padding; };
cbuffer externalData : register(b0) { DirectionalLight dirLight[MAX_DIRECTIONAL_LIGHT_NUM]; PointLight pointLight[MAX_POINT_LIGHT_NUM]; float3 cameraPosition; int pointLightCount; int dirLightCount; }
float Attenuate(float3 position, float range, float3 worldPos) { float dist = distance(position, worldPos); float numer = dist / range; numer = numer * numer; numer = numer * numer; numer = saturate(1 - numer); numer = numer * numer; float denom = dist * dist + 1; return (numer / denom); }
// Lambert diffuse float3 LambertDiffuse(float3 kS, float3 albedo, float metalness) { float3 kD = (float3(1.0f, 1.0f, 1.0f) - kS) * (1 - metalness); return (kD * albedo / PI); }
// GGX (Trowbridge-Reitz) float SpecDistribution(float3 n, float3 h, float roughness) { float NdotH = max(dot(n, h), 0.0f); float NdotH2 = NdotH * NdotH; float a = roughness * roughness; float a2 = max(a * a, MIN_ROUGHNESS);
float denomToSquare = NdotH2 * (a2 - 1) + 1;
return a2 / (PI * denomToSquare * denomToSquare); }
float3 Fresnel_Schlick(float3 v, float3 h, float3 f0) { float VdotH = max(dot(v, h), 0.0f); return f0 + (1 - f0) * pow(1 - VdotH, 5); }
float3 Fresnel_Epic(float3 v, float3 h, float3 f0) { float VdotH = max(dot(v, h), 0.0f); return f0 + (1 - f0) * exp2((-5.55473 * VdotH - 6.98316) * VdotH); }
// Fresnel term - Schlick approx. float3 Fresnel(float3 v, float3 h, float3 f0) { return Fresnel_Epic(v, h, f0); }
float3 FresnelSchlickRoughness(float3 v, float3 n, float3 f0, float roughness) { float NdotV = max(dot(v, n), 0.0f); float r1 = 1.0f - roughness; return f0 + (max(float3(r1, r1, r1), f0) - f0) * pow(1 - NdotV, 5.0f); }
// Schlick-GGX float GeometricShadowing(float3 n, float3 v, float3 h, float roughness) { // End result of remapping: float k = pow(roughness + 1, 2) / 8.0f; float NdotV = max(dot(n, v), 0.0f);
// Final value return NdotV / (NdotV * (1 - k) + k); }
// Cook-Torrance Specular float3 CookTorrance(float3 n, float3 l, float3 v, float roughness, float metalness, float3 f0, out float3 kS) { float3 h = normalize(v + l);
float D = SpecDistribution(n, h, roughness); float3 F = Fresnel(v, n, f0); float G = GeometricShadowing(n, v, h, roughness) * GeometricShadowing(n, l, h, roughness); kS = F; float NdotV = max(dot(n, v), 0.0f); float NdotL = max(dot(n, l), 0.0f); return (D * F * G) / (4 * max(NdotV * NdotL, 0.01f)); //return (D * F * G) / (4 * max(dot(n, v), dot(n, l))); }
float3 AmbientPBR(float3 normal, float3 worldPos, float3 camPos, float roughness, float metalness, float3 albedo, float3 irradiance, float3 prefilteredColor, float2 brdf, float shadowAmount) { float3 f0 = lerp(F0_NON_METAL.rrr, albedo.rgb, metalness); float ao = 1.0f; float3 toCam = normalize(camPos - worldPos);
float3 kS = FresnelSchlickRoughness(toCam, normal, f0, roughness); float3 kD = float3(1.0f, 1.0f, 1.0f) - kS; kD *= (1.0f - metalness);
float3 specular = prefilteredColor * (kS * brdf.x + brdf.y); float3 diffuse = irradiance * albedo;
float3 ambient = (kD * diffuse + specular) * ao;
return ambient; }
float3 DirectPBR(float lightIntensity, float3 lightColor, float3 toLight, float3 normal, float3 worldPos, float3 camPos, float roughness, float metalness, float3 albedo, float shadowAmount) { float3 f0 = lerp(F0_NON_METAL.rrr, albedo.rgb, metalness); float ao = 1.0f; float3 toCam = normalize(camPos - worldPos); //float atten = Attenuate(light.Position, light.Range, worldPos); float3 kS = float3(0.f, 0.f, 0.f); float3 specBRDF = CookTorrance(normal, toLight, toCam, roughness, metalness, f0, kS); float3 diffBRDF = LambertDiffuse(kS, albedo, metalness);
float NdotL = max(dot(normal, toLight), 0.0);
return (diffBRDF + specBRDF) * NdotL * lightIntensity * lightColor.rgb * shadowAmount; }
這裡的光照衰減我用的是虛幻的,具體可以看上面貼的那篇虛幻的文章
然後是直接光pass
float4 main(VertexToPixel pIn) : SV_TARGET { float4 packedAlbedo = gAlbedoTexture.Sample(basicSampler, pIn.uv); float3 albedo = packedAlbedo.rgb; float3 normal = gNormalTexture.Sample(basicSampler, pIn.uv).rgb; float3 worldPos = gWorldPosTexture.Sample(basicSampler, pIn.uv).rgb; float roughness = gOrmTexture.Sample(basicSampler, pIn.uv).g; float metal = gOrmTexture.Sample(basicSampler, pIn.uv).b;
float3 finalColor = 0.f; float shadowAmount = 1.f;
for (int i = 0; i < pointLightCount; i++) { shadowAmount = 1.f; float atten = Attenuate(pointLight[i].Position, pointLight[i].Range, worldPos); float lightIntensity = pointLight[i].Intensity * atten; float3 toLight = normalize(pointLight[i].Position - worldPos); float3 lightColor = pointLight[i].Color.rgb;
finalColor = finalColor + DirectPBR(lightIntensity, lightColor, toLight, normalize(normal), worldPos, cameraPosition, roughness, metal, albedo, shadowAmount); }
for (int i = 0; i < dirLightCount; i++) { float shadowAmount = 1.f; float lightIntensity = dirLight[i].Intensity; float3 toLight = normalize(-dirLight[i].Direction); float3 lightColor = dirLight[i].DiffuseColor.rgb;
return float4(finalColor, 1.0f); }
間接光pass
float4 main(VertexToPixel pIn) : SV_TARGET { float4 packedAlbedo = gAlbedoTexture.Sample(basicSampler, pIn.uv); float3 albedo = packedAlbedo.rgb; float3 normal = gNormalTexture.Sample(basicSampler, pIn.uv).rgb; float3 worldPos = gWorldPosTexture.Sample(basicSampler, pIn.uv).rgb; float roughness = gOrmTexture.Sample(basicSampler, pIn.uv).g; float metal = gOrmTexture.Sample(basicSampler, pIn.uv).b; float shadowAmount = 1.f;
float3 viewDir = normalize(cameraPosition - worldPos); float3 prefilter = PrefilteredColor(viewDir, normal, roughness); float2 brdf = BrdfLUT(normal, viewDir, roughness); float3 irradiance = skyIrradianceTexture.Sample(basicSampler, normal).rgb;
float3 finalColor = AmbientPBR(normalize(normal), worldPos, cameraPosition, roughness, metal, albedo, irradiance, prefilter, brdf, shadowAmount); return float4(finalColor, 1.0f); }
最後進入後處理,這一步我現在只做了HDR到LDR的色調映射和gamma校正
float4 main(VertexToPixel pIn) : SV_TARGET { float3 direct = gDirectLight.Sample(basicSampler, pIn.uv).rgb; float3 ambient = gAmbientLight.Sample(basicSampler, pIn.uv).rgb;
float directIntensity = 1.0f; float ambientIntensity = 1.0f; float3 totalColor = direct * directIntensity + ambient * ambientIntensity;
totalColor = totalColor / (totalColor + float3(1.f, 1.f, 1.f)); totalColor = saturate(totalColor); float3 gammaCorrect = lerp(totalColor, pow(totalColor, 1.0 / 2.2), 1.0f); return float4(gammaCorrect, 1.0f); }
最後就輸出到屏幕上得到結果了。
寫著寫著不知不覺就一萬多字了,寫的過程中我也加深了一下對PBR的理解,接下來一段時間我可能會把引擎代碼重構一下,現在我比較想知道的是渲染器部分和管其他邏輯的引擎內核部分要解耦的話要具體落實到哪些細節上,可能會需要動手清理下代碼,以免以後想加個opengl進來會受太多苦。 這篇文章中間很可能漏了一些小東西忘了提,一開始寫的時候想到了好多,寫著寫著就都不記得了,如果有什麼錯誤歡迎批評指正23333。