1. 程式人生 > >IL2CPP 深入講解:程式碼生成之旅(二)

IL2CPP 深入講解:程式碼生成之旅(二)

IL2CPP 深入講解:程式碼生成之旅


IL2CPP INTERNALS: A TOUR OF GENERATED CODE




這是IL2CPP深入講解系列的第二篇博文。在這篇文章中,我們會對由il2cpp產生的C++程式碼進行分析。我們會看到託管程式碼中的類在C++中如何表示,對.NET虛擬機器提供支援的C++程式碼執行時檢查等功能。

後面例子會使用特定版本的Unity,隨著以後新版本的Unity釋出,這些程式碼可能會有所改變。不過這沒有關係,因為我們文中將要提到的概念是不會變的。

示例程式

我將用到Unity 5.0.1p1來建立示例程式。和第一篇博文一樣,我建立了一個空的專案,新增一個檔案,加入如下內容:



using UnityEngine;

public class HelloWorld : MonoBehaviour {

private class Important {

public static int ClassIdentifier = 42;

public int InstanceIdentifier;

}

void Start () {

Debug.Log("Hello, IL2CPP!");

Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);

var importantData = new [] {

new Important { InstanceIdentifier = 0 },

new Important { InstanceIdentifier = 1 } };

Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);

Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);

try {

throw new InvalidOperationException("Don't panic");

}

catch (InvalidOperationException e) {

Debug.Log(e.Message);

}

for (var i = 0; i < 3; ++i) {

Debug.LogFormat("Loop iteration: {0}", i);

}

}

}


把平臺切換到WebGL,並且開啟“Development Player”選項以便我們能得到相對可以閱讀的函式,變數名稱。我還將“Enable Exceptions”設定到“Full”以便開啟異常捕捉。

生成程式碼總覽

在WebGL專案生成之後,產生的C++檔案可以在專案的Temp\StagingArea\Data\il2cppOutput目錄下找到。一但Unity Editor關閉退出,這個臨時目錄就會被刪除。相反的,只要Editor還開著,這個目錄就會保持不變,方便我們對其檢視。

雖然這個示例專案很小,只有一個C#程式碼檔案,但是il2cpp還是產生了很多檔案。我發現有4625個頭檔案和89個C++檔案。要處理這麼多程式碼檔案,我個人喜歡用Exuberant CTags 文字編輯工具。它可以快速的生成程式碼檔案標籤,讓瀏覽理解這些程式碼變得更容易。

一開始,你會發現這些生成的C++檔案都不是來源於我們那個簡單的C#程式碼,而是來源於諸如mscorlib.dll 這樣的C#標準庫。正如我們在第一篇文章中提到的,IL2CPP後臺使用的標準庫和Mono使用的庫是同一套,沒有任何區別。需要注意的是當每次構建專案的時候,il2cpp.exe都會把這些標準庫轉換一次。貌似這沒啥必要,因為這些庫檔案是不會改變的。

然而,在IL2CPP的後端處理中,通常會使用位元組碼剝離(byte code stripping)技術來減少可執行檔案的尺寸。因此遊戲程式碼的一小點變化也會導致標準庫引用的改變,並影響最終剝離程式碼。所以目前我們還是在每次生成專案的時候轉換所有的標準庫。我們也在研究是否有其他更好的方法可以加快專案生成的速度,但目前為止還沒有好的進展。

託管程式碼如何對映到C++程式碼

在託管程式碼中的每個類,il2cpp.exe都會相應的生成一個有著C++定義的標頭檔案和另外一個進行函式宣告的標頭檔案。舉個例子,讓我們看看UnityEngine.Vector3是如何被轉換的。這個類的標頭檔案名字叫UnityEngine_UnityEngine_Vector3.h。標頭檔案名的組成:一開始是程式集名稱(這裡是UnityEngine),然後跟著名稱空間(還是UnityEngine),最後是這個型別的名字(Vector3)。標頭檔案的內容如下:



// UnityEngine.Vector3

struct Vector3_t78

{

// System.Single UnityEngine.Vector3::x

float ___x_1;

// System.Single UnityEngine.Vector3::y

float ___y_2;

// System.Single UnityEngine.Vector3::z

float ___z_3;

};


il2cpp.exe對Vector3中三個成員都進行了轉換,並且適當的處理了下變數名字(在成員變數前面新增下劃線)以避免和保留字衝突。

UnityEngine_UnityEngine_Vector3MethodDeclarations.h標頭檔案中則包含了Vector3這個類中所有相關的函式。比如我們熟悉的ToString函式:



// System.String UnityEngine.Vector3::ToString()

extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR


請大家注意函式前面的註釋,它能很好的反應出這個函式在原本託管程式碼中的名稱。我時常發現這些個註釋非常有用,能讓我在C++程式碼中快速定位我想要尋找的函式。

由il2cpp.exe生成的函式程式碼有著以下一些有趣的特性:

  • 所有的函式都不是成員函式。也就是說函式的第一個引數永遠都是“this”指標。對於託管程式碼中的靜態函式而言,IL2CPP會傳遞NULL作為第一個引數的值。這麼做的好處是可以讓il2cpp.exe轉換程式碼的邏輯更加簡單並且讓代理函式的處理變得更加容易。

  • 所有的函式還有一個額外的MethodInfo*引數用來描述函式的元資訊。這些元資訊是虛擬函式呼叫的關鍵。Mono使用和特定平臺相關的方法來傳遞這些元資訊。而IL2CPP出於可移植方面的考慮,並沒有使用這些和平臺相關的特定程式碼。所有的函式都被宣告成了extern “C”,這樣一來,在需要的時候我們就可以騙過C++編譯器讓其認為所有這些函式都是一個型別。
  • 託管函式中的型別會被加上“_t”的字尾,函式則是加上“_m”字尾。最後我們加上一個唯一的數字來避免名字的重複。這些數字會隨著專案程式碼的改變而改變,因此你不能把數字作為索引或者分析的參照。

前兩個指標暗示著每個函式都至少有兩個引數:“this”和“MethodInfo*”。這些額外的引數會加重整個呼叫的負擔麼?理論上是顯而易見會加重的,但是我們在實際的測試中還沒有發現這些引數對效能產生影響。

我們可以用Ctags工具跳轉到ToString函式的定義部分,位於Bulk_UnityEngine_0.cpp檔案中。在這個函式中的程式碼看上去和C#中Vector3::ToString()的程式碼一點也不像。但是當你用ILSpy 獲取到Vector3::ToString()內部的程式碼後,你會發現C++程式碼和C#的IL程式碼是十分接近的。

為什麼il2cpp.exe不針對每一個類中的函式生成單獨的一個cpp檔案呢?看看Bulk_UnityEngine_0.cpp,你會發現它有驚人的20,481行!之所以這麼做的原因是我們發現C++編譯器在處理大量的檔案時會有問題。編譯四千多個.cpp檔案所用的時間遠比編譯相同的程式碼量,但是集中在80個.cpp檔案中所用的時間要長得多。因此il2cpp.exe將所有類的函式定義放到一個組裡併為這個組生成C++檔案。

現在讓我們看看函式宣告標頭檔案的第一行:



#include "codegen/il2cpp-codegen.h"


il2cpp-codegen.h檔案中包含了用來呼叫執行時庫libil2cpp的程式碼。我們在稍後會談談呼叫執行時庫的一些方法。

函式預處理程式碼段(Method prologues )

讓我們再仔細的看下Vector3::ToString()函式的定義,你會發現函式中有一段特有的程式碼,這段程式碼是il2cpp.exe模板產生的,會插入到任何函式的最前面。



StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);

static bool Vector3_ToString_m2315_init;

if (!Vector3_ToString_m2315_init)

{

ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);

Vector3_ToString_m2315_init = true;

}


程式碼的第一行是一個區域性變數StackTraceSentry。這個變數是用來跟蹤託管程式碼的堆疊呼叫的。有了這個變數,IL2CPP就能在Environment.StackTrace呼叫中正確的打印出堆疊資訊。是否產生這行程式碼是可選的,當你在il2cpp.exe命令列中加入--enable-stacktrace開關(因為我在WebGL選項中設定了“Enable Exceptions”為“Full”),就會生成這行程式碼。我們發現對於簡單的小函式來說,這行程式碼的加入對程式碼的執行效能是有影響的。所以對於iOS或者其他有內建棧資訊的平臺來說,我們不會加入這行程式碼(而使用平臺內建的棧資訊)。但是對於WebGL來說,由於是在瀏覽器中執行,所以沒有系統內建的棧資訊可供呼叫。只能由il2cpp.exe加入以便託管程式碼的異常機制能正常運作。

程式碼序的第二部分是陣列或者和型別相關的元資訊的延遲載入。ObjectU5BU5D_t4實際代表的是System.Object[]。這部分程式碼永遠只執行一次,如果這個型別的元資訊已經載入過了,就直接跳過這段程式碼,啥也不做。所以這段程式碼不會帶來效能下降。

那麼這段程式碼是執行緒安全的嘛?如果兩個執行緒都同時進行Vector3::ToString() 呼叫會發生什麼?實際上,這不會有任何問題,因為libil2cpp執行時中的型別初始化函式是執行緒安全的。不管初始化函式被多少個執行緒同時呼叫,實際的執行是同一時間只能有一個執行緒的函式在執行。其他執行緒的函式都會被掛起直到當前的函式處理完成。所以總的來說,程式碼是執行緒安全的。

執行時檢查

函式的下個部分建立了一個object陣列,將Vector3的x存在區域性變數中,然後將這個變數裝箱並加入到陣列的零號位置中。下面是生成的C++程式碼:



// Create a new single-dimension, zero-based object array

ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));

// Store the Vector3::x field in a local

float L_1 = (__this->___x_1);

float L_2 = L_1;

// Box the float instance, since it is a value type.

Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);

// Here are three important runtime checks

NullCheck(L_0);

IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);

ArrayElementTypeCheck (L_0, L_3);

// Store the boxed value in the array at index 0

*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;


在IL程式碼中沒有出現的三個執行時檢查是由il2cpp.exe加入的。

  • 如果陣列為空,NullCheck程式碼會丟擲NullReferenceException異常。

  • 如果陣列的索引不正確,IL2CPP_ARRAY_BOUNDS_CHECK程式碼會丟擲IndexOutOfRangeException異常。

  • 如果加入陣列的型別和陣列型別不符合,ArrayElementTypeCheck程式碼會丟擲ArrayTypeMismatchException異常。

這三個檢查本來都是由.NET虛擬機器完成的,在Mono實現中,不會插入這些個程式碼而是使用平臺相關的訊號機制來進行檢查。對於IL2CPP,我們希望做到和平臺無關的可移植性並且還要支援像WebGL這樣的平臺,所以不能使用Mono的機制,而是顯示的插入檢查程式碼。

這些檢查會引起效能的下降麼?在大多數情況下,我們並沒有看到由此帶來的效能損失,並且好處是我們提供了.NET虛擬機器需要的安全保護機制。在某些特定的場合,比如在大量的迴圈中,我們確實看到了效能的下降。目前我們正在尋找方法在il2cpp.exe生成程式碼的時候減少這些執行時檢查,各位有興趣的可以繼續關注。

靜態變數

我們已經瞭解了例項變數(Vector3)如何運作,現在讓我們來看看託管程式碼中的靜態變數是如何轉換成C++程式碼並使用的。讓我們找到HelloWorld_Start_m3函式,這個函式應該在Bulk_Assembly-CSharp_0.cpp檔案中。從這個函式我們找到一個叫Important_t1的型別(這個型別應該是在U2DCSharp_HelloWorld_Important.h標頭檔案裡)



struct Important_t1 : public Object_t

{

// System.Int32 HelloWorld/Important::InstanceIdentifier

int32_t ___InstanceIdentifier_1;

};

struct Important_t1_StaticFields

{

// System.Int32 HelloWorld/Important::ClassIdentifier

int32_t ___ClassIdentifier_0;

};


大夥兒可能注意到了,il2cpp.exe將生成的C++程式碼分成了兩個結構,一個結構負責普通的成員變數,另一個結構負責靜態成員。因為靜態成員是所有例項共享的資料,因此在執行的時候,Important_t1_StaticFields只有一份。所有的Important_t1例項都共享這個資料。在生成的程式碼中,通過下面的程式碼來獲取靜態資料:



int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);


在Important_t1的元資訊結構中有一個指向Important_t1_StaticFields結構的指標(static_fields),然後通過型別轉換再取出需要的值(___ClassIdentifier_0)

異常

在託管程式碼中的異常會被il2cpp.exe轉換成C++的異常。我們再一次的選擇了這個策略還是出於可移植性的考慮:去掉和平臺相關的方案。當il2cpp.exe需要轉換生成一個託管的異常的時候,它會呼叫il2cpp_codegen_raise_exception函式。

在我們的例子中,生成的C++異常處理程式碼如下:



try

{

// begin try (depth: 1)

InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));

InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);

il2cpp_codegen_raise_exception(L_17);

// IL_0092: leave IL_00a8

goto IL_00a8;

} // end try (depth: 1)

catch(Il2CppExceptionWrapper& e)

{

__exception_local = (Exception_t8 *)e.ex;

if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))

goto IL_0097;

throw e;

}

IL_0097:

{ // begin catch(System.InvalidOperationException)

V_1 = ((InvalidOperationException_t7 *)__exception_local);

NullCheck(V_1);

String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);

Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);

// IL_00a3: leave IL_00a8

goto IL_00a8;

} // end catch (depth: 1)


所有的託管異常都被封裝進了il2CppExceptionWrapper的C++型別。當C++程式碼捕獲了這種異常之後,會試圖將包解開獲得託管異常(Exception_t8)。就這個例子而言,我們期待的是一個InvalidOperationException異常,所以當我們發現丟擲的異常不是這個型別的時候,程式碼會建立一個C++異常的拷貝並重新丟擲。反之如果異常正是我們所關注的,程式碼就會跳到異常處理的那段。

Goto是個什麼鬼?跳轉語句!?!

這段程式碼有一個有意思的地方:大夥兒發現了labels標籤和goto語句沒有?這些不太使用的東西居然出現在了結構化的程式碼中(譯註:主流觀點都不建議使用labels和goto語句,因為這會破壞程式的結構化導致各種bug的產生)。為什麼會這樣?因為IL!IL是沒有諸如for,while迴圈和if/then判斷結構化概念的低等級的語言。因為il2cpp.exe需要處理IL程式碼,因此也會出現goto語句。

還是看例子,讓我們看看HelloWorld_Start_m3函式中的迴圈是個啥樣子的:



IL_00a8:

{

V_2 = 0;

goto IL_00cc;

}

IL_00af:

{

ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));

int32_t L_20 = V_2;

Object_t * L_21 =

Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);

NullCheck(L_19);

IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);

ArrayElementTypeCheck (L_19, L_21);

*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;

Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);

V_2 = ((int32_t)(V_2+1));

}

IL_00cc:

{

if ((((int32_t)V_2) < ((int32_t)3)))

{

goto IL_00af;

}

}


在這裡變數V_2是迴圈的索引,從0開始,在迴圈程式碼的最後進行累加。



V_2 = ((int32_t)(V_2+1));


迴圈的結束檢查程式碼:



if ((((int32_t)V_2) < ((int32_t)3)))


只要V_2小於3,goto語句就會跳轉到IL_00af標籤處,也就是迴圈的一開始繼續執行。你可能會想:嗯。。il2cpp.exe一定在偷懶,直接使用了IL的程式碼而不是使用抽象的語法分析樹。如果你是這麼想的,那麼恭喜你猜對了。。。 你可能還會注意到在上面的這段執行時檢查的程式碼中,有下面的情況:



float L_1 = (__this->___x_1);

float L_2 = L_1;


很顯然, 變數L_2不是必須的,大多數的C++編譯器會將其優化掉。對於我們來說,我們在想辦法不去生成這行程式碼(譯註:因為il2cpp.exe是從IL進行程式碼的轉換,沒有使用高階的語法分析,所以會產生多餘的程式碼)。我們也在研究使用高階的抽象語法樹(Abstract Syntax Tree,縮寫:AST)以便更好的理解IL程式碼從而產生更好的C++程式碼(譯註:可能以後就會去除goto跳轉語句了)

總結

通過一個簡單的專案,我們初窺了IL2CPP如何將託管程式碼轉換成C++程式碼。如果你沒有生成測試專案,我強烈建議你做一遍並進行一些研究。在你做這件事的同時,請記住,在後續Unity的版本中,生成的C++程式碼可能會和本文有所不同。這是正常的,因為我們在不斷的改進和優化IL2CPP。

通過將IL程式碼轉換成C++,我們能夠獲得在可移植和效能上的一個很好的平衡。我們能擁有高效開發的託管程式碼的同時,還能獲得高質量的C++程式碼。

相關推薦

IL2CPP 深入講解程式碼生成

IL2CPP 深入講解:程式碼生成之旅 IL2CPP INTERNALS: A TOUR OF GENERATED CODE 這是IL2CPP深入講解系列的第二篇博文。在這篇文章中,我們會對由il2cpp產生的C++程式碼進行分析。我們會看到託管程式碼中的類在C++中如何

IL2CPP 深入講解程式碼生成

上次我們翻譯了由Unity開發人員JOSH PETERSON所寫的、IL2CPP深入講解系列的第一期,現在第二期的中文版也新鮮出爐,歡迎大家分享給身邊的程式設計師。 IL2CPP INTERNALS: A TOUR OF GENERATED CODE 作者:JOSH PETERSON 翻

我的C#跨平臺開發一組標準的Restful API

ref 運行 mar margin bruce ora soft left 啟用 添加NuGet引用:Microsoft.AspNet.WebApi.Owin 在啟動類啟用WebApi; 添加一個Controller類,代碼如下: 運行程序

Servlet學習生命週期

Servlet 生命週期 Servlet 生命週期可被定義為從建立直到毀滅的整個過程。以下是 Servlet 遵循的過程: Servlet 通過呼叫 init () 方法進行初始化。 Servlet 呼叫 service() 方法來處理客戶端的請求。 Servlet

Kotlin的SpringAOP面向切面程式設計

AOP(面向切面程式設計) AOP是OOP(面向物件程式設計)的延續,但是它和麵向物件的縱向程式設計不同,它是一個橫向的切面式的程式設計。可以理解為oop就是一根柱子,如果需要就繼續往上加長,而aop則是在需要的地方把柱子切開,在中間加上一層,再把柱子完美

Zigbee第一個CC2430程式——LED燈閃爍實驗

一、承上啟下      在上一篇文章《Zigbee之旅(一):開天闢地》中,我們簡要的介紹了Zigbee,以及其開發環境的搭建。OK,現在工具都齊全了,一個問題隨之產生:如何利用這些軟、硬體來編寫一個能夠跑起來的程式呢?      本篇文章基本是來回答以上問題的:以“LED燈閃爍”這個小實驗作為例子,介紹

Cocos2D遊戲主角血條的實現

血槽血量的變化是有兩個元素構成的: 1.血槽圖 2.紅色的進度條 當進度條的進度值不斷減少的時候,就產生血量減少的效果。 UI的佈局我採用了CocoStudio的UI編輯器,感覺不錯,挺容易上手的,如果需要相關資料的給我留言。繼續上圖~ 二、再說實現 bool HelloWorld::init()

小白的linux學習

探索linux一、linux系統結構linux是一個倒樹結構linux中所有的東西都是文件這些文件都在系統頂級目錄“/” /就是根目錄/目錄以下為二級目錄這些目錄都是系統裝機時系統自動建立的二級目錄的作用/bin 二進制可執行文件也就是系統命令/sbin

Node.js學習-----MongoDB的安裝與啟動

tar 商業 blank script img blog javascrip ref es2017 安裝與啟動MongoDB Windows 用戶向導:https://docs.mongodb.com/manual/tutorial/install-mongodb-on-

代碼遷移- 漸進式遷移方案

std api 接收 小事 業務邏輯 hidden img 優先級 default * { color: #3e3e3e } body { font-family: "Helvetica Neue", Helvetica, "Hiragino Sans GB", "Micr

Ajax--XMLHttpRequest

net 情況 接收 window soft intern choice 分享 t對象 ? ? ?上文中提到的Ajax的異步更新。主要使用XMLHttpRequest對象來實現的,XMLHttpRequest對象能夠在不向s

webpack入坑loader入門

pts 文章 加載 dep javascrip mode 這就是 插件 可能 這是一系列文章,此系列所有的練習都存在了我的github倉庫中vue-webpack 在本人有了新的理解與認識之後,會對文章有不定時的更正與更新。下面是目前完成的列表: 引子 在上一篇博客中我們已

dotNet程序員的Java爬坑

模式 最好的 https servlet 很多 過濾器 () 被調用 回調   囉裏囉唆的寫了一大堆,最後還是全刪除了。哎~   言歸正傳,最近因爲發生了很多事情,所以更新的有嗲晚了,最近也一直在學習,但是感覺效率什麼的不是很高,這是不對的,反思一下,從這篇博文開始,打起精

Spring Cloud探索——Spring Cloud Eureka

2.1 什麼是服務註冊與發現 在服務治理框架中,通常都會構建一個註冊中心,每個服務單元向註冊中心登記自己提供的服務,包括服務的主機與埠號、服務版本號、通訊協議等一些附加資訊。註冊中心按照服務名分類組織服務清單,同時還需要以心跳檢測的方式去監測清單中的服務是否可用,若不可用需要從服務清單中剔除,以

機器學習

吳恩達教授的機器學習課程的第二週相關內容: 1、多變數線性迴歸(Linear Regression with Multiple Variables) 1.1、多維特徵 x

python學習

Python基礎知識(1) 一、變數 變數名可以由字母、數字、下劃線任意組合而成。 注意:1.變數名不能以數字開頭;            2.變數名不能為關鍵字;           &n

記錄我的Python學習time庫的基本操作

1、time() 功能:獲取當前時間戳,即計算機內部時間值,浮點數  2、ctime() 功能:獲取當前時間並以易讀方式表示,返回字串 3、gmtime() 功能:獲取當前實踐,表示為計算機可處理的時間格式  4、時間格式化:如t=time.gmtime()  

dart- 內建類型

() bool numbers 長度 16進制 exp libraries 全部 dst 像大多數語言一樣,dart也提供了number,string,boolean等類型,包括以下幾種: numbers strings booleans lists (also know

Java架構師

夜光序言: 裝逼是什麼,就是看見野花不摘,欣賞;什麼是衝動,就是見花就摘,然後沒地擱;男人是什麼,那是眼睛裡根本就沒有野花,全是果~         正文:Java企業級高併發  

vivi橙的搬磚

“運動”主題創作 在上一篇文章中,我介紹了利用p5.js繪製一個靜態的圖片的過程。但事實上,很多規律運動的物體也是十分具有美感的。比如下圖: 在這兩幅動圖中,方塊兒經過一系列的變換回到初始的位置,迴圈往復。同時,三原色的應用簡潔、讓人舒適。接下來,我們一起來用Unity實現第二個動圖的