Jelen cikk egy sorozat része. A bevezetőt és a többi listát itt éred el: Unity Top 5
Ahogy már korábban, a bevezetőben írtam, ez a lista lesz a legtechnikaibb. Emellett kiemelném, hogy lekorlátoztam magam olyan pontokra, amik a motor magjával kapcsolatosak. Plugin-okat és olyan eszközöket, amit a PackageManager-rel kell telepíteni, itt nem kritizálok. Még akkor sem, ha az Unity First Party plugin. Nem lesznek felsorolva olyan funkciók sem, amik hiányoznak a Unity-ből, csak olyanok, amik már léteznek és valami böki a csőröm velük kapcsolatban.
Szeretnék mindazonáltal igazságos lenni a Unity-vel. A következő problémák nagy részével úgy vélem, hogy a cég is tisztában van. Nem egyszerű azonban egy már korábban megírt funkciót kigyomlálni vagy átalakítani, mikor milliók használják azt.
Nem egyszerű, de nem is lehetetlen…
5. Inkonzistens lefutási sorrend
A MonoBehaviour üzenetmetódusok alapvető részét képezik a Unity-nek. Ezek működése általában nagyon hasonló. Egy esemény hatására a motor végig nézi az összes jelenetben lévő aktív szkriptet és sorban meghívja az összes érintett metódust. Ezután lép át a következő eseményre.
Például az összes Update le fog futni azelőtt, hogy lefutna az első LateUpdate, vagy az egyszerre létrehozott komponensek esetén az összes Awake le fog futni azelőtt, hogy lefutna az első Start.
Ez a rendszer tiszta és érthető. Sajnos létezik pár kivétel: Vegyük teszem azt az Awake és az OnEnable metódust. Ha például létrehozok egyszerre 2 komponenst A-t és B-t, akkor ezek esetén a metódusok lefutási sorrendje a következő:
A Awake és A OnEnable, majd B Awake és B OnEnable
Ezen kivételek létezése is önmagában elég lenne ahhoz, hogy piszkálja az ember OCD-jét, de valószínűleg nem lenne elég ahhoz, hogy bekerüljenek a személyes kis szégyenlistámra.
A nagyobb probléma az, hogy egy konkrét metódus lefutásának sorrendje a különböző komponenseken csak nagyon korlátozottan szabályozható.
Csupán minden MonoBechaviour típushoz rendelhetünk egy prioritást, ami sorrendjében történik a függvényhívás, ám ez a megoldás nehezen karbantartható és sok eseményre nem is működik.
4. Publikus, szerializált és vizuális field-ek
- Minden publikus mező egyszerre szerializált, de nem minden szerializált mező publikus.
- Minden szerializált mező megjelenik az Inspectoron, és semmi más.
Az én szememben ez a három tulajdonság, ami leír egy tagváltozót (publikusság, szerializáltság, inspector megjelenítés) teljesen független kéne legyen egymástól.
Értem én, hogy van kapcsolat a fentiek közt és gyakran egymás mellett kéz a kézben járnak, de azt nem miért köti meg a kezünket a Unity abban, hogy szeparáltan kezeljük ezen tulajdonságokat.
Persze mindent meg lehet oldani, de gyakran fölöslegesen komplikált és korlátozó módon.
- Lehet nem szerializált mezőt megjeleníteni, editor szkripttel.
- Lehet szerializált mezőt eltakarni a [HideInInspector] attribútummal
- Publikus mező, helyett lehet használni property-t, amit nem szerializál a Unity.
- stb…
Ám mindezek az én szememben felesleges és bosszantó komplikációk, amik csak arra valók, hogy kevésbé legyen olvasható a kód, nehezebb legyen kibogozni, a szerző szándékát.
Nem lenne egyszerűbb, ha ezen tulajdonságok mindegyikét függetlenül lehetne jelezni? A láthatóságot más osztályok számára a C# bevett módosítóival: public, protected, stb., a szerializálhatóságot és vizuális megjelenítést pedig rövid és kifejezőbb nevű attribútumokkal pl.: [Setting] és [Show].
Ha már itt tartunk, egy property is nyugodtan lehetne szerializálható, egy statikus tag pedig megjeleníthető. De ekkor már talán túl szép is lenne az élet.
3. Editor assembly
Van a programozásban egy tervezési minta, amit Model–view–controller (MVC)-nek nevezünk. A fő gondolata az architektúrának, hogy érdemes a logikai működést elkülöníteni a felhasználói felülettől.
Kevesen vonják kétségbe ezen gondolat hasznosságát. De minden szent és dicső programozási mintát lehet túl- és rosszul használni. Ezt nevezzük overenginering-nek, amit talán túltervezésnek lehetne fordítani. Az én szememben ez a jelenség történik a Unity Editor szkriptjei esetén is.
Az Inspector felület módosítására egy új osztályt kell létrehozni egy speciálisan elnevezett “Editor” mappában. A gond ezzel az, hogy ez az editor szkript általában hozzá akar férni a komponens minden vagy legtöbb belső adatához. Emiatt mindent publikussá kell tenni a komponensben, akkor is ha bizonyos változók módosítását, metódusok hívását nem szeretnénk engedni más szkripteknek.
(Tudom, vannak olyan függvények, amikkel név szerint egy string-gel lehet hivatkozni változókra Editor szkriptből és általa rajzolni ki azt. Ennek a módszernek megvannak a maga problémái, amiről egy későbbi pontban írok. A problémát mélyíti, hogy a teljes Editor könyvtár egy külön assembly-ben van, ami nem kerül bele a végleges buildbe. Ezáltal a private protected módosítóval sem használhatjuk a változókat.)
Nem is túl konzisztens a Unity ezen téren. Például az OnDrawGismos üzenet-metódust a Unity együtt kezeli a többi játékot befolyásoló metódussal, márpedig az semmiképp nem a komponens logikai működésének , “üzleti logikájának” része, pusztán vizualizáció.
Mégis ezzel nekem semmi bajom. Ha egy kicsit túlterjedne egy MonoBechaviour osztályom, semmi akadálya annak, hogy két partial file-re bontsam azt, amiket Model–view–controller határok mentén bontok fel.
Miért nem lehetnek az Inspectort kirajzoló Callback metódusok a MonoBehaviour-ben az OnDrawGismos-hoz hasonlóan. Ekkor a fejlesztő el tudná dönteni, hogy elkülöníti-e a Modellt és a View-t, nem lenne kényszerítve rá.
Ugyanez igaz a Handle-ök használatára is. (Handle - Olyan gizmó, amivel interakcióba lehet lépni).
Ezen kérdésben jobban látom az érme mindkét oldalát, mégis kiállnék amellett, hogy a Unity a rosszabbik döntést hozta.
2. String-összehasonlítás
Vegyük a GameObject.Find() metódust. Arra való ez a függvény, hogy egy string alapján visszaadjon egy GameObject-et a betöltött Scene-ből. Kezdő fejlesztők gyakran használják ezt egy [SerializeField] referencia helyett. Ez szerintem hiba. A gond az a név szerinti kereséssel, hogy felesleges redundanciát hoz be a projetbe és ezáltal törékenyebbé teszi azt, a szükségesnél.
Egy objektum neve két helyen lesz így jelen a projektben: Egyszer az editorban és egyszer a kódban. Itt a redundancia. Ezek közül bármelyiket írja át a fejlesztő, az hibás működéshez vezet. És ami még rosszabb, ezt a hibát nem is garantált, hogy hamar észrevesszük.
Ez talán nem hat nagy gondnak, akkor érzi át igazán a fejlesztő a probléma súlyát, amikor egy bizonyos méreten túllép a projekt. Ekkor eljuthat a fejlesztés arra a pontra, amikor senki nem mer semmit átnevezni mert attól fér, hogy az valahol szerepel a kódban és eltör vele valamit.
Megoldás lehet, hogy minden átnevezés előtt mindenki rákeres a kódban az adott névre. Ez azonban csak egy csúnya és lassú megkerülése a problémának. Nem beszélve arról, hogy a projekten dolgozhatnak dizájnerek is, akik nem járatosak a kódszerkesztésben.
Amit most leírtam egy esernyőprobléma, ami nem korlátozódik csak a GameObject.Find metódusra. A Unity-ben több esetben is név szerint kell vagy lehet hivatkozni programozási elemekre és ezen string összehasonlítások mindegyikével ugyanaz a gon, amit előbb tárgyaltam:
- GameObject.Find(”GameObject neve”);
- Invoke(”Metódus neve”);
- UI elemek név szerint hívnak metódusokat
- Animációs eventek név szerint hívnak metódusokat
- Tag-ekkel kapcsolatban minden
- LayerMask.NameToLayer
- GUI és EditorGUI metódusok paramétereiben név szerint kell hivatkozni más metódusokra
- …
Szerencsére van pár érv a unity védelmében, amik tompítják a problémát.
- Általában (nem mindig) létezik jó alternatív megoldás, amivel elkerülhető a string összehasonlítás.
- A C# 6.0 verziótól kezdődően a nyelv része a nameOf( ), amivel lekérdezhető egy programozási elem neve string-ben. Ez sok esetben teljesen megoldja a gondunkat.
Amik lemaradtak a listáról
Mielőtt kihírdetném a személyes kedvenc Unity funkciómat, ami napi szinten kerget őrületbe, vegyünk számba pár feature-t, amik csak kicsivel maradtak le az ötös listáról.
- Kamera csak függőlegesen állítható
- Unity Szerializáció korlátok
- Nem szerializálható mátrix (legalább 2D)
- Nem szerializálható Dictionary
- Nem szerializálható sorozat sorozata. (Tömbök tömbje, listák listája)
- Nem szerializálhatunk adatot 7 szintű absztrakciós mélységen túl.
- Ctrl C + Ctrl V hiánya a Projekt ablakban
- Tag-ek
- A == operátor Unity.Object osztályok esetén
Perspektív kamera esetén a FieldOfView, ortografikus esetben pedig a kamera méret csak a függőleges irányon értelmezett.
Perspektív esetben még “hazudik” is erről a Unity. Az Inspectoron átállítható vízszintesbe a fieldOfView. Ez azonban senkit ne tévesszen meg! Ugyanúgy a függőleges érték kerül eltárolásra. Aki nem hiszi, csak próbálja átméretezni a Game ablakot.
Azért bocsánatos mindez, mert egy rövid saját kóddal áthidalható a probléma.
Nem sokon múlt, hogy ez a pont bekerüljön az ötbe.
Szerializátort írni nem egyszerű, de nem is olyan szintű probléma, amit egy Unity méretű cég ne tudna menedzselni. Nem igazán látom, hogy olyan korlátozások, amik 10 éve jelen voltak már a motorban, miért nincsenek befoltozva mára. Ezek közül kiemelném, a kövekezőket:
Ezek egyikéhez sem kéne boszorkányság.
Ezt a hiányosságot hála Istennek már kijavított a Unity az újabb verzióiban. A most kezdő fejlesztők nem is tudják milyen fejfájást úsztak meg. Azért hozom mégis fel ezt, mert a trauma, amit az évek alatt okozott, hogy Unity-ben nem tudok másolni és beilleszteni a fájlok közt, talán egész életemen keresztül végigkisér majd.
Ugyancsak túl hosszú ideig létezett, de mára kijavított Editor probléma volt a listák és tömbök átrendezhetőségének hiánya.
Teljesen felesleges Unity-ben. Tisztáznám, nem a tag-ek koncepciójával van bajom, hanem a Unity megoldásával. Először is string összehasonlítást használ. (Hogy ez miért gond, azt lásd fent.) Másodszor csak egy tag adható egy GameObject-hez, ami egy elég nagy felesleges korlátozás.
Javaslom, hogy tag-ek helyett mindenki használjon ScriptableObject-eket, amiket egy egyszerű saját komponenssel rendelhetünk egy GameObjecthez.
A motor korai időszakában ezt az operátort felüldefiniálták a fejlesztők, amit azóta már bevallottan megbántak. Ennek oka az, hogy a Unity egy C++ nyelven írt motor, ami C#-ban szkriptelhető. Emiatt a UnityEngine.Object osztályok leszármazottai igazából mindig két objektum képviseli. Egy C# a felhasználói oldalon és egy C++ a motorháztető alatt.
Ha egy C++ objektum már megszűnt létezni, de a hozzá tartozó C# objektum még nem, akkor a nullteszt igazzal tér vissza a még létező C# objektum-ra is.
Komplikáltnak hangzik? Hát az is. Sokkal jobb lenne, ha az == operátort senki nem definiálná felül soha.
És most dobpergés:
1. AddForce / AddTorque
Fú. Ez hosszú lesz. Hol is kezdjem?
Nem hiszem, hogy sokak személyes listáján első helyen lenne ez a két függvény, sőt lehet, hogy az is ritka, hogy egyáltalán megemlítenék, de nálam az egész cikk ötletét ez a pár metódus adta.
Szóval a Unity fizikában alapvetően néhány külöböző módon manipulálhatjuk 2D és 3D Rigidbody-k sebesség-vektorát.
Vector3 v = new Vector3(1,2,3); // valami vektor
// 0. Egy felől szimplán beállíthatjuk a vektort:
rigidbody.velocity = v;
// 1. Pilanatszerűen módosíthatjuk egy értékkel:
rigidbody.velocity += v;
// 2. Pilanatszerűen módosíthatjuk egy értékkel úgy,
// hogy a test tehetetlenségét adó tömeget is számításba vesszük:
// Mivel minnél nehezebb a test, annál nehezebb mozgatni, szóval a tömeggel osztunk.
// Ez megfelel annak, mintha a lendületet módosítanánk.
rigidbody.velocity += v / rigidbody.mass;
// 3. Folyamatosan kifejthetünk egy állandó gyorsulást a testre:
// Folyamatos változásra számítógépes szimuláció esetén egy ciklust használunk, úgy
// hogy a ciklus minen végrehajtásakor szorzunk az előző lefutás óta eltelt idővel.
// Unity-ben erre való az Update és FixedUpdate metódus. (Itt FixedUpdate-t használunk)
rigidbody.velocity += v * Time.fixedDeltaTime;
// 4. Folyamatosan kifejthetünk egy állandó gyorsulást a testre úgy,
// hogy a tömeget is számításba vesszük.
// Ez megfelel annak, mintha folyamatosan egy erőt fejtenénk ki a testen.
rigidbody.velocity += v * Time.fixedDeltaTime / rigidbody.mass;
Két szabály van tehát amit meg kell jegyezni:
- Ha azt szeretnénk, hogy a hatás folyamatos legyen (és nem pedig pillanatszerű), akkor szorzunk az előző ciklus lefutás óta eltelt idővel. (Time.fixedDeltaTime)
- Ha azt szeretnénk, hogy a tömeg is számítson, akkor egyszerűen osztunk vele.
Eddig a pontig szerintem minden a lehető legtisztább. Persze nagyon is megértem, ha egy kezdő fejlesztőnek idő kell ahhoz, hogy felfogja a fentieket. Ez természetes. Viszont ezen koncepciók megértése semmiképp nem megspórolható. Ez főleg az a. pontra igaz, amit a fizikai szimuláció világán kívül is feltétlenül meg kell értenie minden játékprogramozónak.
A Unity fejlesztői nem így gondolták. Ők úgy érezték, hogy mindez így túl bonyolult és a fentiek komplexitását egy metódus mögé kell rejtsék. Véleményem szerint azzal ahogy ezt megtették, pont az ellenkező hatást érték el. Erre a célra létrehozták az AddForce metódust, amivel az 4. pontban lévő módon tudunk (folyamatos) erőt adni a RigidBody-hoz.
Ezzel eddig nem sok problémám lenne, talán csak annyi, hogy a fixedDeltaTime-mel történő szorzás el van takarva a felhasználó elől. Ez a módszer kötelezi, a fejlesztőt, hogy a FixedUpdate-ben végezze a műveletet, de sem a metódus neve sem a leírása ezt nem kommunikálja le tisztán. Ezáltal könnyen elköveti a kezdő programozó azt a hibát, hogy az Update-ben használja az AddForce-ot, ami erősen inkonzisztens eredményhez vezet. Tisztáznám, sosem ajánlott Update-ben végezni semmilyen fizikai szimulációt, de ha valaki gyakorlatlanság okán mégis ott teszi, akkor sokkal jobb, ha a legalább Time.deltaTime-mal szoroz.
Ott kezdődik az igazi átláthatatlanság, hogy az AddForce-hoz tartozik egy extra enum paraméter is, amiben befolyásolni lehet, hogy a fent felsorolt módok közül hogyan működjön.
Vector3 v = new Vector3(1,2,3);
Rigidbody rb = GetComponent<Rigidbody>();
rb.AddForce(v, ForceMode.VelocityChange); // 1. (Pillanatszerű, Tömeg NEM számít)
rb.AddForce(v, ForceMode.Impulse); // 2. (Pillanatszerű, Tömeg számít)
rb.AddForce(v, ForceMode.Acceleration); // 3. (Folyamatos, Tömeg NEM számít)
rb.AddForce(v, ForceMode.Force); // 4. (Folyamatos, Tömeg számít)
Szóval az AddForce (Erő hozzáad) metódussal közlünk más fizikai mennyiségeket is a testtel, nem csak erőt. Miért nincs erre a célra 4 szeparált metódus egyedi kifejező névvel? Ahogy én látom ezzel nem egyszerűsítettek a kezdők dolgán, hanem nagyon is komplikáltak.
És ez még semmi. Ha bevesszük a képbe a körkörös mozgást is, akkor még zavaróbb a kép.
A forgó mozgás fizikájában ugyanis minden mennyiségnek megfeleltethető egy másik, a lineáris fizikában használt mennyiség. Pl.:
Szögsebesség → Sebesség,
Szöggyorsulás → Gyorsulás
Impulzusnyomaték (Perdület) → Lendület
Forgatónyomaték → Erő
A fent felsorolt mennyiségek mindegyikének párját fejből kell tudni a Unity fizika használatához, ugyanis a Unity a forgómozgás szimulálására az AddTorque (Forgatónyomaték hozzáadás) metódust használja, ami ugyanúgy a ForceMode enum-mal paraméterezhető.
Szóval például, ha a perdületet akarjuk módosítani, akkor az AddTorque(vector, ForceMode.Impulse) metódust kell hívni. A művelet így két fizikai mennyiség nevét is tartalmazza, amiből egyik sem a perdület! Hogy ez mégis miféle módon tenné átláthatóvá és kezdőbaráttá a használatot, az messze felülmúlja az én képzelőerőmet.
És még mindig nincs teljesen vége. 2D-s fizikában ugyanúgy létezik AddForce és AddTorque metódus a RigidBody2D komponensen. Ezen metódust szintén fel lehet paraméterezni egy ForceMode-dal, ám ez nem ugyanaz a ForceMode, mint amit 3D-ben használtunk. Ez egy új típus, a ForceMode2D, ami a ForceMode beállításai közül csak kettőt tartalmaz. A VelocityChange és az Acceleration hiányzik. Ennek az ég világon semmi jó oka nincsen, csak annyi, hogy még kevéssé legyen konzisztens a rendszer használata. Tehát ha mondjuk sebességváltozást akarunk közölni egy 2D testtel, akkor AddForce-ot kell hívni ForceMode2D.Impulse paraméterrel és szorozni a tömeggel. Megint csak a művelet két külön fizikai mennyiség nevét is tartalmazza, amiből egyik sem az, amin változtatni szeretnénk.
Remélem, senki kedvét nem vettem el a játékmotor használatától. Nem ez volt a célom. Muszáj kiemelnem, hogy minden keretrendszer, ami csak megközelíti a Unity komplexitását tartalmazni fog ilyen és ehhez hasonló kényelmetlenségeket és tervezési hibákat.
Mindezek ellenére is egy remek választásnak tartom a motort kezdők és professzionális fejlesztők számára egyaránt.
Szerző: Marosi Csaba
📞 Telefonszám: +36 20 359 7422
📧 E-mail cím: marosicsaba91@gmail.com
Ha érdekel a kódolás és játékfejlesztés, fontold meg a jelentkezést egyik tanfolyamomra→