ZG Connect logoZG Connect

Building Interaction — hover, selekcija i zašto su collideri odvojeni od prikaza

Ovaj dokument objašnjava BuildingInteractionController i prateće skripte iz Assets/ZGConnect/Runtime/BuildingInteraction/ — sustav koji omogućuje da igrač mišem pređe preko zgrade (hover highlight), klikne na nju (selekcija + info popup), unutar Runtime_Streaming scena (radi zajedno s RealtimeStreamingController, opisanim u Fazi 3).

Fokus ovog dokumenta nije na Inspector opcijama (te su već navedene u Fazi 3, dio "Building interaction" na RealtimeStreamingController-u), nego na principu rada: kako sustav zna na koju je zgradu igrač kliknuo kad su sve zgrade jedne pločice spojene u jedan mesh, i zašto postoji odvojena "fizička" hijerarhija samo za collidere.

Zašto je ovo uopće problem

U Fazi 3 (RealTime Asset Importer / Spatial bake) opisano je da se, radi performansi, sve zgrade jedne pločice kombiniraju u jedan (ili nekoliko, po materijalu) zajednički mesh — "CombinedRender" — umjesto da svaka zgrada ostane zaseban GameObject s vlastitim rendererom. To je namjerno: stotine pojedinačnih zgrada bi inače značile stotine draw callova po pločici, dok kombinirani mesh znači jedan (ili šačicu, po broju materijala).

Problem: kombinirani mesh nema pojedinačni identitet po zgradi. Ne postoji "renderer zgrade broj 42" kojem bi se moglo promijeniti boju/materijal da bi se ta jedna zgrada highlightala — sve zgrade dijele isti draw call. Isto vrijedi i za samo prepoznavanje klika: standardni Unity raycast pogađa Collider, ne renderer, pa raycast na kombinirani vizualni mesh (ako bi uopće imao collider) ne bi mogao reći koja pojedinačna zgrada je pogođena.

Rješenje u ovom projektu ima dva odvojena mehanizma za dva odvojena problema:

  1. Zasebna, netaknuta "fizička" hijerarhija collidera — po jedan collider po zgradi, čuva identitet svake zgrade radi raycastanja — čisto radi interaktivnosti, ne radi fizike u smislu sudaranja igrača s objektima (iako ta ista hijerarhija služi i za to)
  2. Privremena "ghost" kopija geometrije samo one jedne zgrade koja je trenutno hoverana/ selektirana, iscrtana zasebno preko posebnog highlight sloja i overlay kamere — jer se kombinirani vizualni mesh ne može parcijalno prebojati

1. Zašto su collideri odvojeni od vizualnog prikaza

Pri pack/bake koraku (Faza 3), building bundle svake pločice sadrži dvije odvojene hijerarhije pod korijenom TileBuildings_{tileId}:

  • TileBuildings_{tileId} → CombinedRender čvor — kombinirani vizualni mesh(evi) po materijalu, bez collidera. Ovo je ono što igrač stvarno vidi
  • TileBuildings_{tileId}_Physics — zaseban podstablo s po jednim GameObjectom po pojedinačnoj zgradi, svaki s vlastitim MeshCollider (ili BoxCollider, ovisno o "Building colliders (pack bake)" postavci opisanoj u Fazi 3 — Box je jeftiniji za pack, Convex Mesh je precizniji za fiziku) — ali bez vlastitog renderera. Ovi objekti su nevidljivi; postoje isključivo da raycast ima o što "udariti" i da zna kojoj zgradi taj pogodak pripada

Ova podjela nije slučajna — direktno proizlazi iz mesh-combine bakea: budući da se vizualna geometrija zgrada spaja i gubi individualni identitet, identitet se čuva odvojeno, u paralelnoj, lakšoj hijerarhiji collidera koja se ne kombinira. RealtimeStreamingController čak i odvojeno kontrolira kad se koja hijerarhija streama: polje Building Collider Load Distance Meters (Faza 3) omogućuje da se vizualni CombinedRender učita odmah pri ulasku pločice u doseg, a fizički (_Physics) prefab tek kad se kamera dovoljno približi — jer collideri imaju CPU trošak (fizički broadphase, memorijski otisak stotina malih collidera) koji nije opravdan dok igrač nije dovoljno blizu da uopće može kliknuti na zgradu.

Kako raycast pronalazi "svoju" zgradu (BuildingHitUtility)

BuildingRaycastInteractor svaki frame baca Physics.RaycastAll iz pozicije miša (preko _raycastMask LayerMaska iz Inspectora), sortira pogotke po udaljenosti i za svaki redom pokušava BuildingHitUtility.TryResolveBuildingRootFromCollider:

  1. Od pogođenog collidera penje se prema gore kroz roditelje tražeći čvor čije ime počinje s TileBuildings_ — to je korijen pločice (tileId se izvlači iz imena)
  2. Zatim se ponovno spušta od pogođenog collidera prema tom korijenu, preskačući čvorove koji su dio CombinedRender podstabla ili sam ..._Physics korijen, i pamti zadnji čvor prije nego što bi roditelj bio korijen pločice ili ..._Physics korijen — to je pojedinačna zgrada (jedan direktni "list" unutar _Physics hijerarhije)

Ako prvi pogodak (najbliži kameri) nije zgrada, provjerava se je li teren (TerrainCollider) — ako je, klik na prazan teren (bez pogotka na zgradu) se koristi za zatvaranje popupa/deselekciju (DismissPopupsRequested), ne za odabir.

2. Zašto se prikaz hovera/selekcije radi kao zasebna "ghost" kopija

Budući da se ne može promijeniti materijal samo jedne zgrade unutar kombiniranog mesha (dijeli ga sa svima ostalima na toj pločici), sustav umjesto toga gradi privremenu, samostalnu kopiju geometrije te jedne zgrade i iscrtava je preko originala.

BuildingGhostBuilder.Build(building, ...) to radi u dva moguća načina:

  1. Ako zgrada još ima vlastite (nekombinirane) LOD0 renderere — kopira geometriju direktno iz njih (RuntimeBuildingMeshUtility.GetLod0Renderers, CloneMeshGeometry) — mesh se klonira jednom i keš-ira (meshCloneCache) kako se ista geometrija ne bi klonirala više puta
  2. Fallback — ako je zgrada dio kombiniranog mesha (nema vlastiti renderer): geometrija ghosta se rekonstruira iz njenog vlastitog collidera — ako je MeshCollider, koristi se njegov sharedMesh (ista geometrija koju je collider koristio za fiziku, sad posuđena za prikaz); ako je BoxCollider, gradi se jednostavna kutija (unit cube mesh skaliran na dimenzije collidera) — grublja, ali dovoljna aproksimacija oblika zgrade za highlight

Ovo je razlog zašto su collideri opisani gore toliko važni i za samu vizualnu stranu interakcije: kad je vizualna geometrija nedostupna (jer je spojena u kombinirani mesh), collider je jedini preostali izvor oblika te pojedinačne zgrade, pa se koristi i za to.

Rezultirajući "ghost" GameObject dobiva highlight/selection materijal (_highlightMaterial / _selectionMaterial iz Inspectora, ili fallback ZGConnect/BuildingOutline shader s bojom i debljinom ruba iz Inspectora ako materijal nije dodijeljen), stavlja se na poseban Unity layer (BuildingHighlight), i renderer mu se namjesti da ne baca sjenu i ne koristi light/reflection probe (ConfigureGhostRenderer) — highlight je čisto vizualni overlay, ne treba mu puni osvjetljeni izgled.

Zašto poseban layer + overlay kamera (BuildingHighlightCamera)

Ghost geometrija je (približno) na istoj poziciji kao i stvarna zgrada — kad bi se iscrtavala u istom prolazu kao i normalna scena, došlo bi do z-fightinga (treperenje zbog identičnih dubina) s originalnim kombiniranim meshom. Zato:

  • Ghost objekti idu na poseban Unity layer BuildingHighlight
  • Glavna kamera se konfigurira da ne iscrtava taj layer (cullingMask &= ~HighlightLayerMask)
  • BuildingHighlightCamera stvara URP overlay kameru naslaganu preko glavne (cameraStack, CameraRenderType.Overlay, clearFlags = Depth) čiji je cullingMask postavljen isključivo na BuildingHighlight layer, i koja kopira FOV/near/far/ortho postavke glavne kamere svaki frame (LateUpdate)

Rezultat: ghost se iscrtava u zasebnom prolazu, preko svega ostalog, bez ovisnosti o z-bufferu originalne geometrije — čist, pouzdan outline/highlight efekt koji ne zahtijeva diranje shared materijala kombiniranog mesha (što bi highlightalo cijelu pločicu odjednom).

3. Hover vs. selekcija — tijek događaja

BuildingInteractionController svaki frame (Update) pita BuildingRaycastInteractor je li interakcija blokirana (pokazivač je nad UI-em, ili je pritisnut desni gumb miša — koristi se za rotaciju kamere pa se tada interakcija namjerno gasi), zatim:

  1. HoverHoveredBuildingChanged event → OnHoveredBuildingChanged. Ako je nova hoverana zgrada null, ili je već selektirana (nema smisla hoverati nešto što je već trajno highlightano), transient highlight se čisti (BuildingHighlightRenderer.Clear); inače se BuildingHighlightRenderer.SetTarget(building) — stari hover ghost se uništi, novi izgradi. Ovo je transientno stanje: postoji samo jedna hoverana zgrada odjednom, briše se čim pokazivač ode s nje
  2. KlikBuildingClicked event → OnBuildingClicked. Metapodaci zgrade se lijeno učitavaju preko BuildingLazyMetadataResolver (dohvaćen s RealtimeStreamingController-a) — ako učitavanje ne uspije, samo se logira upozorenje i ništa se ne selektira. Ako uspije: zgrada se dodaje u BuildingSelectionRenderer (trajno stanje — mapa Transform → ghost GameObject, zgrada ostaje selektirana dok se eksplicitno ne ukloni), transient hover ghost se čisti (jer selekcija preuzima prikaz), i otvara se info popup (BuildingInfoPopupManager.SpawnPopup) s učitanim podacima
  3. Klik izvan zgrade (na teren ili u prazno) → DismissPopupsRequested → sve selekcije se brišu (BuildingSelectionRenderer.ClearAll), svi popupi se zatvaraju, _selectedBuildingInspector se resetira

Za razliku od hovera (jedna zgrada, brzo se mijenja), selekcija je zamišljena kao skup — više zgrada može ostati selektirano istovremeno (svaka sa svojim ghost objektom u _ghosts rječniku), dok se eksplicitno ne poništi klikom izvan zgrada.

Obje varijante (BuildingHighlightRenderer.Tick() / BuildingSelectionRenderer.Tick()) se svaki frame provjeravaju — ako je referentna zgrada u međuvremenu deaktivirana ili uništena (npr. streaming ju je rasteretio jer je kamera otišla predaleko), ghost se automatski čisti kako ne bi ostao "lebdjeti" bez originala.

Sažetak — princip u jednoj rečenici

Zgrade se vizualno kombiniraju radi performansi i time gube individualni render-identitet; identitet se čuva u paralelnoj hijerarhiji nevidljivih collidera koje raycast koristi da prepozna koja je zgrada pogođena, a kad treba tu zgradu i prikazati izdvojeno (hover/selekcija), sustav gradi privremenu kopiju njene geometrije — iz izvornog mesha ako postoji, ili iz istog tog collidera ako ne postoji — i iscrtava je u zasebnom sloju/kameri preko originala, umjesto da pokuša mijenjati dijeljeni materijal cijele pločice.