如何在unity實現足夠快的2d動態光照
關於Deferred light/Forward light
瞭解3d遊戲中光照的人,應該很熟悉deferred和forward這兩種不同的實現方式。
簡而言之,deferred light是以這種方式繪製的:
1.對每一個場景中的Mesh
- 將其法線繪製到法線貼圖上
- 將其顏色繪製到顏色貼圖上
- 將其深度繪製到深度貼圖上
2.對每一個燈光,對三張貼圖進行取樣,繪製到螢幕上。
而forward light是以這種方式繪製的:
1.對每一個場景中的Mesh,對每一個燈光,將其繪製到螢幕上
最直接的差別是,對於數量為M的Mesh,數量為L的光源而言,deferred light的draw call次數為O(M+L),而forward light為O(ML)。
在2d光照中也可以用同樣的概念去理解,可以用deferred及forward兩種不同的方式去實現。
我最終選擇了deferred的方式去實現,像這樣:
1.對每一個光源,如果沒有被剔除(在攝像機外),則將其光照繪製到一個等同螢幕比例的光照貼圖上。
2.在繪製場景完中每一個精靈(Sprite)/粒子/骨骼動畫以後,將光照貼圖以一個quad mesh的方式繪製到螢幕上,使用相乘的blend方式。
這樣可以保證我可以方便地在任意一個已開發到一定複雜度的遊戲中加入這個光照系統,而無需改動場景中原來任意Renderer的繪製Shader。
同時為了光照能夠讓場景中的物體呈現不同的細節,我們可以很方便地加入法線貼圖,具體可以參考這篇文章。
硬陰影
兩年以前,我實現了一個只有硬光源的簡陋光照系統。
那個時候,我主要受到這篇文章的啟發,學習到如何用線性及二分的Raycast來獲得周圍遮擋體的輪廓和邊緣。
不過我的思路也侷限於此,當時完全想不出如何繪製一個過渡足夠平滑的陰影邊緣。
那個時候,在將光源繪製到光照貼圖這一步上,我是這麼做的(只考慮點光源):
1.均勻地用Raycast遍歷光源周圍,對突變(兩條射線的終點不是同一個碰撞體或是法線的差距很大)的地方進行二分,得到在極座標上的遮擋點資訊。
2.生成一個光照Mesh。例如,一個沒有任何遮擋的點光源會生成一個近似圓形的Mesh:
而有遮擋的光源會生成一個殘缺的Mesh:
上圖中NMLK點與GFED等點本質上並沒有什麼不同,只不過NMLK在Raycast中擊中了實在的碰撞體,而GFED在達到光源範圍最遠處時擊中了“假想碰撞體”。
因為光源的強度會隨距離衰減,我們為光照Mesh中不同的頂點賦值不同的顏色值使之中心最亮,邊緣最暗(2d中的光源,線性衰減效果已經足夠好)。
其中獲取周圍遮擋點的實現可以參考:
- public class CircleHitPoint {
- public float radius;
- public LayerMask colliderLayer;
- public float binaryMaxDegree = 5;
- public int rayCount;
- public Vector2 center;
- public struct HitInfo {
- public RaycastHit2D hit2D;
- public float angle;
- public HitInfo(RaycastHit2D hit2D, float angle)
- {
- this.hit2D = hit2D;
- this.angle = angle;
- }
- public Vector2 Position(Vector2 center, float radius) {
- if(hit2D) {
- return hit2D.point;
- }
- else {
- return center + CircleHitPoint.Degree2Dir(angle) * radius;
- }
- }
- }
- private static Vector2 Degree2Dir(float degree) {
- float rayRad = Mathf.Deg2Rad * degree;
- Vector2 dir = new Vector2(Mathf.Cos(rayRad), Mathf.Sin(rayRad));
- return dir;
- }
- private RaycastHit2D AngleRayCast(float angle) {
- var rayDir = Degree2Dir(angle);
- var hit = Physics2D.Raycast(center, rayDir, radius, colliderLayer);
- return hit;
- }
- public Vector2 Position(HitInfo info) {
- return info.Position(center, radius);
- }
- public float NormedHitRadius(HitInfo info) {
- return Mathf.Clamp01((Position(info) - center).magnitude / radius);
- }
- private IEnumerable<HitInfo> BinaryFindEdgeAndReturnPoint(HitInfo info1, HitInfo info2) {
- if(rayCount < 3) rayCount = 3;
- Func<RaycastHit2D, RaycastHit2D, bool> hitSame = (hit1, hit2) => {
- if(!hit1 && !hit2) {
- return true;
- }
- else if(hit1 ^ hit2) {
- return false;
- }
- else {
- return hit1.collider == hit2.collider;
- }
- };
- Func<RaycastHit2D, RaycastHit2D, bool> normalSame = (hit1, hit2) => {
- return (!hit1 && !hit2) || Mathf.Approximately((hit1.normal - hit2.normal).magnitude, 0);
- };
- if((hitSame(info1.hit2D, info2.hit2D) && normalSame(info1.hit2D, info2.hit2D))
- || info2.angle - info1.angle < binaryMaxDegree) {
- yield return new HitInfo(info2.hit2D, info2.angle);
- yield break;
- }
- var midDegree = (info1.angle + info2.angle) / 2;
- var midHit = AngleRayCast(midDegree);
- var midHitInfo = new HitInfo(midHit, midDegree);
- foreach(var hitInfo in BinaryFindEdgeAndReturnPoint(info1, midHitInfo)) {
- yield return hitInfo;
- }
- foreach(var hitInfo in BinaryFindEdgeAndReturnPoint(midHitInfo, info2)) {
- yield return hitInfo;
- }
- }
- //返回每個遮擋點
- public IEnumerable<HitInfo> RaycastPoints() {
- float deltaDegree = 360.0f / (float) rayCount;
- float lastDegree = 0;
- RaycastHit2D lastHit = new RaycastHit2D();
- for(int i = 0; i < rayCount + 1; i++) {
- float rayDegree = deltaDegree * i;
- var hit = AngleRayCast(rayDegree);
- if(i > 0) {
- var lastHitInfo = new HitInfo(lastHit, lastDegree);
- var currentHitInfo = new HitInfo(hit, rayDegree);
- var hitInfos = BinaryFindEdgeAndReturnPoint(lastHitInfo, currentHitInfo);
- foreach(var hitInfo in hifInfos) {
- yield return hitInfo;
- }
- }
- else {
- yield return new HitInfo(hit, rayDegree);
- }
- lastHit = hit;
- lastDegree = rayDegree;
- }
- }
- }
這種方式用來做硬陰影的效果非常好,也很簡單,我一度覺得這是最終的解決方案。
在後續實現軟陰影的時候,我沒有放棄這個做法,而是在繪製完整個光照貼圖以後,對光照貼圖做了一次高斯模糊(其實更好的做法是對每個光源繪製的光照進行光照朝向的法線模糊)。
然而高斯模糊的開銷非常大,導致遊戲在一些對後處理支援的不是很好的平臺上奇慢無比(之前在開發的一個採用模糊的方式來實現軟陰影的遊戲在我的Macbook Pro上只有20FPS)。
這使我回過頭來思考:對於2d遮擋的軟陰影,有沒有更好更快的實現方式?
之後我看到了http://GameDev.net上的這篇文章,讓我一拍腦袋。原來沒有必要一次性將光照畫出來,而是可以分為幾步:
1.Clear陰影貼圖(一張單通道的,等同螢幕比例的貼圖)
2.對每個光源:
- 繪製該光源造成的陰影到陰影貼圖上
- 將光源繪製到光照貼圖上(同時對陰影貼圖進行取樣)
原文中,所謂的陰影貼圖即是光照貼圖的Alpha通道,然而我在unity中做不到只Clear一張貼圖的Alpha通道,所以我選擇建立一張只有R通道的貼圖作為陰影貼圖。
我從中還得到的一個提示是:每個陰影區塊的繪製,是以遮擋體(Shadow hull)的一條邊為單位的。
什麼意思?舉個例子,假設我要繪製一條邊AB的硬陰影,我生成這樣一張陰影Mesh:
其中DE是AB的平行線,圓的半徑是光源的最大衰減距離(該距離外不繪製光照)。原文中的做法是將DE邊投射得足夠遠直至大於螢幕寬度,而我這邊的做法是將其投射至光源最大衰減距離處,也就是說DE是該圓的切線。
具體的Mesh生成程式碼如下:
- void UpdateShadowMesh() {
- if(shadowMesh == null) shadowMesh = new Mesh();
- shadowMesh.MarkDynamic();
- shadowMesh.Clear();
- List<Vector3> vertices = new List<Vector3>();
- List<int> triangles = new List<int>();
- CircleHitPoint.HitInfo? previous = null;
- foreach(var current in circleHitPoint.RaycastPoints()) {
- if(!current.hit2D) {
- previous = null;
- }
- else {
- if(previous != null) {
- // Consume previous is A, current is B
- if(previous.Value.hit2D.collider == current.hit2D.collider) {
- Vector2 A = circleHitPoint.Position(previous.Value);
- Vector2 B = circleHitPoint.Position(current);
- Vector2 C = circleHitPoint.center;
- Vector2 AB = B - A;
- Vector2 normal = new Vector2(AB.y, -AB.x).normalized;
- Vector2 CA = A - C;
- float dis = Vector2.Dot(CA, normal);
- float scale = circleHitPoint.radius / dis;
- Func<Vector2, Vector2> project = v2 => (v2 - C) * scale + C;
- triangles.Add(vertices.Count + 0);
- triangles.Add(vertices.Count + 3);
- triangles.Add(vertices.Count + 2);
- triangles.Add(vertices.Count + 0);
- triangles.Add(vertices.Count + 1);
- triangles.Add(vertices.Count + 3);
- vertices.Add(WorldV2ToLocalV3(A));
- vertices.Add(WorldV2ToLocalV3(B));
- vertices.Add(WorldV2ToLocalV3(project(A)));
- vertices.Add(WorldV2ToLocalV3(project(B)));
- }
- }
- previous = current;
- }
- }
- shadowMesh.SetVertices(vertices);
- shadowMesh.SetTriangles(triangles, 0);
- }
程式碼中的數學計算很簡單,參照圖示應該很好理解。
在繪製光源時對陰影貼圖取樣,令光照值和陰影值相乘,繪製到光照貼圖上。
由於我們不再通過Mesh頂點的顏色來傳遞光照距離縮減,我們每個光源可以用一張簡單的quad mesh來繪製,而光照距離衰減在fragment shader中解決。
點光源的fragment shader是這樣的:
- float4 frag(v2f IN) : COLOR
- {
- float2 dir = IN.world - _LightPos;
- float norm = length(dir) / _LightMaxDis;
- norm = saturate(norm);
- float4 c = float4(_LightColor.xyz * smoothstep(0, 1, (1 - norm)), 1);
- c.xyz *= 1 - tex2D(_ShadowMap, IN.screen.xy).r;
- return c;
- }
陰影貼圖是反色繪製的(陰影處是白,非陰影處是黑,這樣可以簡單地Blend add),所以在取樣相乘的時候需要1-c.r來反色回去。而光照距離衰減我用了一個smoothstep函式使之更加平滑。
軟陰影
在著手如何實現軟陰影之前,我們先來討論一下,軟陰影是什麼?
我們總是光源抽象成一個沒有體積的點光源。對場景中任意一點,如果該點與光源之間沒有遮擋,該點亮度為0,否則為1,如下圖:
然而現實生活中不存在絕對理想的點光源,光源是有體積的。一點E的亮度是光源體積上所有與E無遮擋的點帶給它的亮度的積分,如下圖:
當光源有了體積以後,如上圖所示,在遮擋體的邊緣處,會出現亮度在0到1之間,過渡平滑的半影區域。
那麼,在繪製陰影貼圖的時候,我們似乎可以完全將半影和全影分開來繪製,如下圖所示:
圖中AAiAo、BBoBi為半影區域,ABBiAi為全影區域,除此之外的區域一定是被整個光源照亮的(相對遮擋邊緣AB而言)。
http://GameDev.net那篇文章給出的方案是,用一張半影貼圖來繪製半影區域:
那麼我們在繪製這張Mesh的時候可以使用如下的uv座標來取樣半影貼圖:
這樣可以一次性將半影與全影一起繪製,效果也非常好。
但是我在實踐過程中發現,當AB中某一點離光源過近,同時AB的角度比較刁鑽的時候,這張陰影Mesh會出現嚴重的變形:
原因很簡單,此時不再存在什麼全影區域,B點的半影區域將整個AB及A的半影區域完全籠罩了。即使我們修正這個扭曲的Mesh,我們只繪製B及只繪製A的半影區域,或是都繪製,結果也是錯誤的。取樣半影貼圖在這個場景下永遠無法正確地繪製陰影。
這讓我萌生了一個想法:能不能只用Mesh表示最小陰影區域,而在fragment shader中計算實際的亮度?
遮擋值計算
由於陰影繪製只是以一條遮擋邊AB為基本單位,在shader中計算遮擋變得不是那麼遙不可及。
我們在計算遮擋時需要如下幾條資訊:
- A點位置
- B點位置
- 光源位置
- 當前繪製片段的世界位置
- 光源的體積半徑(我們假設所有體積光源都是球光源/圓光源)
我們在繪製陰影時疊加的是遮擋值,而非亮度值,完全被遮擋時遮擋值為1,完全不被遮擋時遮擋值為0。(亮度值=1-遮擋值)。
我們不需要追求完全擬真的遮擋計算,在實踐中,可以將遮擋值近似為(被遮擋的角度/光照總角度),如下圖
此處的遮擋值為∠AEB/∠FEG=α/β=8.5/27=0.3148
由於在此場景下,求過點E的圓C的兩條切線EF、EG是十分昂貴的計算,我們還可以對光照角度的兩條邊進行近似:
此處光照角度為∠FEG,FG為與CE垂直的直徑。
計算遮擋值也很簡單,我們設EG及EF邊中其中一個為起始邊,一個為終結邊,在其之間的點的角度值標準化為[0,1]之間的一個數。那麼:
注意在計算時,若EP向量在EG向量的逆時針方向,∠PEG是負值。
問題在於,到底哪條該當作起點,這是不能隨意定的。
為什麼?我們來繼續往下看。
這是我在shader中計算向量夾角的函式:
- // [-180, 180]
- float dirAngle(float2 v) {
- float angle = atan2(v.y, v.x);
- angle = degrees(angle);
- return angle;
- }
- // [-360, 360] norm to [-180, 180]
- float normAngle(float angle) {
- angle = angle - step(180, angle) * 360;
- angle = angle + step(angle, -180) * 360;
- return angle;
- }
- // [-180, 180]
- float dirBetweenAngle(float2 v1, float2 v2) {
- return normAngle(dirAngle(v1) - dirAngle(v2));
- }
注意,返回夾角的函式返回的是一個絕對值在180°以內,帶符號的角度(以順時針為正方向)。
假設我們全部片段將EG當作起始邊計算遮擋值。
如上圖所示,當AB橫跨過EG邊的兩種情況時,計算的遮擋值必然是正確的。
但是若AB不橫跨EG,同時EA向量相對EG向量在逆時針方向時,計算的遮擋值將會是錯誤的,如下:
在上圖中,A在我們設想中應該遮擋掉∠FEB部分的光照,但是由於我們的角度計算方式,取了角度小於180度的那一邊,A點的值在clamp前是一個負值,導致其計算結果是遮擋掉了∠BEG部分的光照。
解決這個錯誤的方式是:
- 判斷AB是否橫跨EG邊(邊EG是否在∠AEB內)
- 若橫跨了,則以EG為起始邊
- 若未橫跨,則以EF為起始邊
在fragment shader中計算遮擋值的最終程式碼如下:
- // [-180, 180]
- float dirAngle(float2 v) {
- float angle = atan2(v.y, v.x);
- angle = degrees(angle);
- return angle;
- }
- // [-360, 360] norm to [-180, 180]
- float normAngle(float angle) {
- angle = angle - step(180, angle) * 360;
- angle = angle + step(angle, -180) * 360;
- return angle;
- }
- // [-180, 180]
- float dirBetweenAngle(float2 v1, float2 v2) {
- return normAngle(dirAngle(v1) - dirAngle(v2));
- }
- float2 _LightPos;
- float _LightVolumeRadius;
- float4 frag(v2f IN) : COLOR
- {
- float2 CE = IN.E - _LightPos;
- // CE的法線
- float2 CENorm = normalize(float2(-CE.y, CE.x)) * _LightVolumeRadius;
- float2 dirF = (_LightPos - CENorm) - IN.E;
- float2 dirG = (_LightPos + CENorm) - IN.E;
- float2 dirA = IN.A - IN.E;
- float2 dirB = IN.B - IN.E;
- float full = dirBetweenAngle(dirF, dirG);
- // 若EA在EB順時針端,為1,否則為0
- float ABiggerThanB = step(0, dirBetweenAngle(dirA, dirB));
- //順時針端的邊
- float2 dirCW = ABiggerThanB * (dirA - dirB) + dirB;
- //偏逆時針端的邊
- float2 dirCCW = dirA + dirB - dirCW;
- //若AB跨過EG,為1,否則為0
- float crossG = step(0, dirBetweenAngle(dirG, dirCCW)) * step(0, dirBetweenAngle(dirCW, dirG));
- float sign = crossG * 2 - 1;
- float2 startingEdge = dirF + (dirG - dirF) * crossG;
- // saturate(x) <=> clamp(x, 0, 1)
- float valueCW = saturate(sign * dirBetweenAngle(dirCW, startingEdge) / full);
- float valueCCW = saturate(sign * dirBetweenAngle(dirCCW, startingEdge) / full);
- float occlusion = abs(valueCW - valueCCW);
- return occlusion;
- }
大家知道我們要儘量避免在shader程式碼中使用條件語句,所以我用了step函式來代替條件判斷。
現在,我們真的可以較為真實地計算一條邊的遮擋了。
最小陰影Mesh
解決了如何在shader中計算遮擋,我們還要考慮如何繪製一個最小的、簡單的、不重疊的陰影Mesh。
在大多數情況下,我們可以簡單地繪製兩個三角形:
由於我們不再需要區分半影與全影區域,我們只需要Ao與Bo點,不再需要Ai與Bi點。其中CBo與CAo也進行了與遮擋計算shader一致的近似。
在前面討論過的,還需考慮B點或A點的半影蓋住另一點的半影,全影部分消失的情況。
這個時候我們甚至不用畫兩個三角形,只需要畫一個三角形就夠了。
我們先要考慮一下,這種情況出現的條件是什麼?
此時A在BBiBo三角形的內部,或者說,A在BBi邊的下方。
設BiB向量順時針90度的法線是BiBNormal,那麼這種情況出現的條件是Dot(BiBNormal,AB)>0。
將此處的A與B交換的情況也是一樣的,在這裡就不再複述。
生成最小陰影Mesh的程式碼如下
- List<Vector3> vertices = new List<Vector3>();
- List<Vector2> apos = new List<Vector2>();
- List<Vector2> bpos = new List<Vector2>();
- List<int> triangles = new List<int>();
- foreach(var edge in circleHitPoint.ExtractEdge()) {
- Vector2 A = edge.A;
- Vector2 B = edge.B;
- Vector2 C = circleHitPoint.center;
- Func<Vector2, Vector2, Vector2> normal = (c, p) => {
- Vector2 dir = p - c;
- return new Vector2(-dir.y, dir.x).normalized;
- };
- Vector2 ABnormal = -normal(A, B);
- Vector2 CAO = normal(C, A) * volumeRadius + C;
- Vector2 CBO = -normal(C, B) * volumeRadius + C;
- Func<Vector2, Vector2, Vector2, Vector2> project = (n, origin, point) => {
- float disToPoint = Vector2.Dot(origin - point, n);
- disToPoint = Mathf.Abs(disToPoint);
- float delta = circleHitPoint.radius - disToPoint;
- delta = Mathf.Max(0, delta);
- float scale = (delta + disToPoint) / disToPoint;
- return (point - origin) * scale + origin;
- };
- if(Vector2.Dot((B - A), normal(A, CAO)) >= 0) {
- Vector2 CBI = normal(C, B) * volumeRadius + C;
- triangles.Add(vertices.Count + 0);
- triangles.Add(vertices.Count + 2);
- triangles.Add(vertices.Count + 1);
- vertices.Add(WorldV2ToLocalV3(B));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(project((B - C).normalized, CBI, B)));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(project((B - C).normalized, CBO, B)));
- apos.Add(A);
- bpos.Add(B);
- }
- else if(Vector2.Dot((A - B), normal(CBO, B)) >= 0) {
- Vector2 CAI = -normal(C, A) * volumeRadius + C;
- triangles.Add(vertices.Count + 0);
- triangles.Add(vertices.Count + 2);
- triangles.Add(vertices.Count + 1);
- vertices.Add(WorldV2ToLocalV3(A));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(project((A - C).normalized, CAO, A)));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(project((A - C).normalized, CAI, A)));
- apos.Add(A);
- bpos.Add(B);
- }
- else {
- triangles.Add(vertices.Count + 0);
- triangles.Add(vertices.Count + 1);
- triangles.Add(vertices.Count + 3);
- triangles.Add(vertices.Count + 0);
- triangles.Add(vertices.Count + 3);
- triangles.Add(vertices.Count + 2);
- vertices.Add(WorldV2ToLocalV3(A));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(B));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(project(ABnormal, CAO, A)));
- apos.Add(A);
- bpos.Add(B);
- vertices.Add(WorldV2ToLocalV3(project(ABnormal, CBO, B)));
- apos.Add(A);
- bpos.Add(B);
- }
- }
- shadowMesh.SetVertices(vertices);
- shadowMesh.SetTriangles(triangles, 0);
- shadowMesh.SetUVs(0, apos);
- shadowMesh.SetUVs(1, bpos);
- this.shadowMesh = shadowMesh;
- return shadowMesh;
優化的餘地
在我描述的方法下,每一幀都需要對光源旁的每個遮擋體邊緣動態生成大量陰影Mesh,這顯然對CPU的負擔非常大。一種優化的做法是陰影Mesh對每個遮擋體的每條邊生成,遮擋體的形狀固定以後不會再變,用不同的uv座標來表示不同的頂點(用不同的uv座標來區分A、Ao、Ai),陰影Mesh的形狀在Vertex shader當中計算。
由於一切計算在shader中可讀性都會變得很差,我在這裡就不展開介紹了,原理都是差不多的。
作者:偽人
專欄地址:https://zhuanlan.zhihu.com/p/52423823
相關文章
- 在Unity中實現2D光照系統Unity
- Unity——基於ShaderLab實現光照系統Unity
- 如何在Unity中實現水體互動?Unity
- 快取架構,一篇足夠?快取架構
- 快時尚服裝品牌ZARAA以“夠快夠新”滿足顧客需求
- Unity的Forward+ FPTL光照剔除解析(四)UnityForward
- Unity的Forward+ FPTL光照剔除解析(一)UnityForward
- Unity的Forward+ FPTL光照剔除解析(三)UnityForward
- Unite 2019|Unity的光照烘焙技術(上)Unity
- 【Unity】(2D)物體拖拽Unity
- 打造真實感十足的速度錶盤:WPF實現動態效果與刻度繪製
- 動態棧的實現
- [Unity][Camera][2D][優化]2D攝像機可視範圍外不播放動畫Unity優化動畫
- unity 實現輪盤方式的按鈕滾動效果Unity
- Unity 中用有限狀態機來實現一個 AIUnityAI
- Unity Shader 基於光照圖的簡易晝夜變化Unity
- [Unity3D] 2D畫素遊戲(一) Hello Unity!Unity3D遊戲
- [Unity] Dreamteck Splines實現沿路徑移動功能Unity
- Laravel 動態屬性的實現Laravel
- 手動實現一個滿足promises-aplus-tests的PromisePromise
- SpringBoot中併發定時任務的實現、動態定時任務的實現(看這一篇就夠了)Spring Boot
- Feign實現動態URL
- 揭祕《Sherman》:使用Unity製作影視級光照效果Unity
- CMO Council:只有20%的品牌能夠滿足現代消費者的需求
- unity 動態修改當前橫豎屏狀態Unity
- 融創中國:有足夠現金應對短期到期的債務
- 詳解Nacos 配置中心客戶端配置快取動態更新的原始碼實現客戶端快取原始碼
- 如何在 pyqt 中使用動畫實現平滑滾動的 QScrollAreaQT動畫
- web動態驗證碼的實現Web
- 寫給美術看的Unity全域性光照技術(理論篇)Unity
- 寫給美術看的Unity全域性光照詳解(引數篇)Unity
- Unity中的光源型別(向前渲染路徑進行光照計算)Unity型別
- Unity中實現人形角色的攀爬Unity
- Unity實現簡單的物件池Unity物件
- 「React」如何在React中優雅的實現動畫React動畫
- UNITY3D 2D物流流體外掛下載|Liquid Physics 2DUnity3DUI
- Unity Shader- UV動畫原理及簡易實現Unity動畫
- GitLab 實現動態 Environment URLGitlab