Search Unity

Spotlight Team은 열정이 넘치는 Unity 개발자들과 함께 Unity 기반 게임의 지평을 넓히고자 합니다. 그 노력의 일환으로 그래픽스, 성능, 디자인과 관련된 복잡한 문제를 해결할 수 있는 혁신적인 아이디어를 모색하는 가운데, 똑같은 문제와 해결책을 몇 번이고 반복해서 경험하기도 합니다.

이 블로그 시리즈는 Spotlight Team에서 클라이언트와 작업을 진행하며 가장 자주 경험하는 문제들을 다룹니다. 지금껏 유능한 팀들과 협업하는 과정에서 겪은 시행착오를 통해 얻어낸 결과를 공유할 수 있어 뿌듯함과 기쁨을 느낍니다.

블로그를 통해 소개하는 문제들 중 다수는 콘솔이나 휴대폰에서 게임을 테스트하거나 방대한 양의 게임 콘텐츠를 다루어 보아야만 비로소 발견하게 되는 문제입니다. 이러한 문제들에 대한 해결책과 도구 활용법을 개발 초기 단계부터 적극적으로 활용한다면, 시간과 열정을 들인 만큼 값진 성과를 거둘 수 있을 것입니다.

Scalability

Unity에서 지원하는 멀티 씬 편집 기능을 활용하여 단일 단위의 게임플레이 또는 기능을 정의하는 팀이 계속 늘어나고 있습니다. 이 때문에 다른 씬의 오브젝트를 참조할 수 없는 Unity의 한계를 극복해야 한다는 부담이 더욱 크게 다가옵니다.

이러한 한계를 극복할 수 있는 방법은 매우 다양합니다. 많은 팀에서는 오브젝트를 직접 참조하는 수고를 덜기 위해 생성자(spawner), 프리팹, 절차적 콘텐츠 또는 이벤트 시스템에 크게 의존하고 있습니다. Unity가 지금껏 가장 흔히 볼 수 있었던 해결책은 게임 오브젝트에 고정적이고 고유한 ID 또는 GUID를 할당하는 것입니다. 고유 ID를 보유하고 있다면 위치나 장소, 로딩 여부에 관계없이 알려진 게임 오브젝트의 인스턴스를 참조하거나 저장된 게임 데이터를 실제 런타임 게임 구조에 매핑할 수 있습니다.

수많은 클라이언트가 이와 같은 문제를 동일한 방법으로 해결하는 것을 지켜보며 Unity는 레퍼런스로 참조할 수 있을만한 구현을 진행하기로 결정했습니다. 그리고 다양한 선택지를 충분히 고려한 후, 공개 API와 C#만을 활용하는 사용자의 입장에서 이 문제에 접근하기로 했습니다. 이를 통해 내부적으로는 훌륭한 테스트 사례를 구축할 수 있었으며, 사용자와 코드를 직접 공유할 수 있게 되었습니다. 더 이상 빌드를 기다릴 필요가 없습니다. Github에 접속해서 코드를 다운로드한 후, 마음껏 사용하고 필요에 맞게 변경해 보실 수 있습니다.

이 솔루션에 대한 자세한 정보는 여기에서 확인하실 수 있습니다.

이 솔루션의 기본 구조는 아주 간단합니다. GUID의 모든 오브젝트를 포함하는 전역 정적 Dictionary가 있기 때문에, 필요한 오브젝트가 GUID를 보유하고 있다면 Dictionary에서 해당 정보를 찾아보면 됩니다. 정보가 있다면 검색 결과를 얻을 수 있으며, 그렇지 않다면 null값을 얻게 됩니다. 모든 유형의 클라이언트 프로젝트에 활용할 수 있도록 최대한 단순한 솔루션을 구성하고자 했습니다. 상기 링크의 GUIDManager.cs에서 추가적인 게임 오브젝트 없이 정적 관리자를 설정하는 방법을 확인해 보세요.

지속성

Unity가 직면한 첫 번째 과제는 System.GUID의 GUID를 Unity가 MonoBehaviour의 일부로 직렬화할 수 있는 형식으로 변환하는 것이었습니다. 다행히도 ISerializationCallbackReciever를 추가하는 것으로 손쉽게 해결할 수 있었습니다. 간단한 성능 테스트를 거친 결과, .ToByteArray()가 .ToString()에 비해 2배 정도 빠른 결과를 보였으며, 관련 없는 메모리를 할당하지도 않았습니다. Unity에서 byte[]를 취급하는 데는 문제가 없기 때문에, 이는 보조 스토리지로 활용하기에도 적합했습니다.

씬에서 게임 오브젝트를 생성하고 GuidComponent를 추가하면 GuidComponent는 새 GUID를 얻게 되고, 해당 GUID는 런타임 메모리에 저장됩니다. 해당 씬이 디스크에 직렬화되면 GUID도 바이트 배열로 직렬화됩니다. 이후 씬이 직렬화가 해제되면 동일한 GUID 역시 직렬화가 해제된 후에 런타임 메모리에 저장됩니다. 하지만 게임 오브젝트를 복사하여 붙여넣기 할 때 문제가 발생합니다.
 
씬에서 게임 오브젝트를 복사하여 붙여넣기 하면 GUID가 충돌하게 됩니다. GUID 충돌을 탐지하고 GuidManager.InternalAdd에서 코드를 수정하여 이 문제를 해결할 수 있습니다.
 
한 씬에서 GuidComponent를 가지는 게임 오브젝트의 프리팹 에셋을 생성하고 Unity 에디터를 다시 시작한 상황에서 다른 씬에서 프리팹 에셋을 인스턴스화한다면 GUID 충돌이 발생할 수 있으며, 해당 충돌을 GuidManager가 바로 탐지하지 못합니다. 이 문제는 다음 코드를 사용하여 프리팹 에셋에서 빈 GUID를 가지도록 하여 수정할 수 있습니다.


그리고 계속되는 테스트에서 이상한 점을 발견했습니다. 백업 프리팹이 손상된 프리팹 인스턴스가 새로운 GUID의 인스턴스 데이터를 저장하지 않는 경우가 종종 발생했습니다. 여기서 PrefabUtility가 다시 한번 해결사로 나섭니다. CreateGuid 함수를 확인해 보시죠.


도구

일단 저장이 정상적으로 완료되면 사용자가 시스템에 데이터를 가져오는 방법을 알 수 있도록 해야 했습니다. 새로운 GUID를 만들기는 쉬웠지만, 이를 참조하는 일은 여전히 까다로운 작업이었습니다. 여러 씬에 걸쳐 레퍼런스를 설정하려면 사용자가 각 씬을 로드해야 한다는 점은 명백합니다. 초기 설정은 간단했습니다. 스탠다드 오브젝트 선택 필드를 찾으려는 GuidComponent로 설정하면 됩니다. 하지만 해당 레퍼런스는 크로스 씬이 될 것이므로 저장하지 않기로 했습니다. Unity는 이를 받아들이지 않고 null을 출력했습니다. 그래서 GuidReference를 일반 오브젝트 필드인 것처럼 드로우하고, 레퍼런스가 아닌 GUID를 저장해야 했습니다.

다행히도 특정 유형의 커스텀 드로어를 구축하고 [PropertyDrawer] 태그를 설정하는 작업은 아주 간단했습니다. 피커를 구축하고, 그 결과로 발생한 옵션을 선택한 후, 본래 목적대로 GUID 데이터를 직렬화했습니다. 하지만 로드되지 않은 씬에 타겟 게임 오브젝트가 포함된 경우에는 어떻게 해야 할까요? 설정된 값이 있음을 사용자에게 알려주고, 해당 오브젝트에 대해 가능한 한 많은 정보를 제공할 방법을 고민했습니다.

노력 끝에 찾아낸 해결책은 다음과 같습니다.

다른 씬에 저장된 오브젝트에 대한 레퍼런스.

미리 설정한 GuidComponent에 대한 비활성 오브젝트 선택자와 타겟이 있는 씬을 포함한 비활성 필드, 마지막으로 현재 선택한 타겟을 삭제(Clear)하는 버튼입니다. 사용자가 오브젝트 피커에서 없음(None)을 선택한 경우와 타겟 오브젝트가 로딩에 실패하여 값이 null이 된 경우의 차이를 식별할 수 없기 때문에 부득이하게 이러한 설정이 필요했습니다. 이러한 설정의 매우 큰 장점은 씬 레퍼런스가 실제 에셋 필드이며, 실제 타겟 오브젝트에서 확보하려는 경우 로드할 씬을 강조 표시한다는 점입니다. 예를 들자면 다음과 같습니다.

타겟을 포함한 씬을 강조 표시한 에셋 핑.

Github의 GuidReferenceDrawer.cs에서 모든 코드를 찾아볼 수 있습니다. 필드를 생성하는 트릭을 수정할 수는 없으나, 여기에서는 여전히 실제 필드의 역할을 하고 있죠.


테스트

모든 요소가 의도대로 구성되었음을 확인한 후에는 테스터와 사용자를 대상으로 이를 검증하고자 했습니다. Unity 내부용 테스트는 작성한 적이 있었으나, 사용자 코드용 테스트는 만들어 본 적이 없었습니다. 솔직히 말씀드리면, Unity 빌트인 테스트 러너(Test Runner)의 실효성을 간과하고 있었습니다. 정말 놀라울 정도로 유용한 툴인데도 말이죠!

제가 원하는 최종 결과물입니다.

창(Window) > 테스트 러너(Test Runner)로 이동하여 플레이 모드(Play Mode)나 편집 모드(Edit Mode)에서 코드에 대한 테스트를 만들 수 있습니다. 만드는 과정은 정말 쉽습니다.

테스트 예시를 통째로 가져와 봤습니다. GUID를 중복으로 설정했을 때 적절한 메시지를 출력하기 위한 테스트입니다.


이 코드는 예상한 경고를 받지 못하면 오류가 발생한다는 메시지를 테스트 도구(test harness)에 전달합니다. 그 다음, 이전에 생성한 GuidComponent를 클로닝하고 GUID 충돌이 발생하지 않았는지 확인한 후 테스트를 종료합니다.

파일 테스트를 시작하기 전에 불러오고자 했던 설정 기능의 [OneTimeSetUp] 속성을 활용하여 테스트를 구성했기 때문에, guidBase에 따라 그 과정은 달라질 수 있습니다. 더 자세한 내용은 여기의 문서에서 참조하실 수 있습니다. 테스트 작성이 얼마나 쉬웠는지, 테스트를 만드는 과정을 거쳐 코드가 얼마나 개선되었는지 확인하며 정말 감탄했습니다. 팀에서 사용 중인 툴이 있다면 종류에 상관없이 테스트해 보기를 적극 권장드립니다.

향후 계획

오늘 살펴본 해결책이 많은 개발자분들께 도움이 되었기를 바랍니다. 곧 Github에서 에셋 스토어 또는 패키지 관리자로 코드를 옮길 예정이며, 모두 무료로 다운로드하실 수 있습니다. 어려움을 겪고 계시거나 제안하실 사항이 있다면 언제든지 자유롭게 공유해 주시기 바랍니다. 앞으로도 Spotlight Team은 Unity를 최대한 활용할 수 있는 다양한 조언과 해결책을 소개해드릴 예정이니 많은 관심 부탁드립니다.

28 코멘트

코멘트 구독

코멘트를 달 수 없습니다.

  1. I just tried to implement this for our use-case. Two problems: PrefabUtility functions seem to be among the functions that can’t be safely called from the serialisation callback, it triggers a “ReleaseAllScriptCaches did not release all script caches” assert when the scene is loaded. This seems to be depending on the scene size, as smaller test scenes work fine. Second, sometimes you want to create a prefab of an object that contains both references between parts of the prefab while it is a prefab, and cross-scene references once it is instantiated in a scene.
    In the end I went for a more traditional implementation that resolves guid collisions in an editor script, and stores both a Unity Object reference and a guid reference depending on how the object can be reached.
    I feel the same way as the first poster: It’s annoying how we as clients have to jump through hoops to solve problems like this on the game side with complicated extra mechanisms, that would be solved trivially on the engine side. (Especially things like this that have been generally solved decades ago…)

  2. So we have to add GuidComponent to all GameObject with cross reference ? Is that really practical for any AAA production? (Or any production)
    Client did a workaround. You are at the engine level, and should work on the engine level. It is doable, as I already did it on a previous big engine I’ve worked on….10 yeras ago.
    So don’t put in the mindset of porting a client solution, as you can get it done, totally transparent for any user, on the serialization level.

    Have a dictionnary with a UID assign to every GameObject.
    When you serialised a Game Object reference (or monobiavior), if it’s crossscene (or in all case), write the UID.

    At load time:
    load all dictionnary of all scene loaded. And then resolve reference to game object.
    If the scene is not loaded, the dictionnary for this UI will return null, but that’s of course a normal behave.
    And it works!

    Of course if you’re like us (client), you can’t do this on the egine level, and you find half broken solution. This is not you’re case.

    And if you ask yourself why Unity is not used by more AAA, it’s not because of a lack of fancy shader, it’s also because it’s not practical to work smoothly at 100 people on the same game with Unity.

  3. I will prefer Package manager over the asset store

  4. Speaking about GUID, why not add GUID Id display into the inspector debug mode? might be useful

    1. William Armstrong

      7월 24, 2018 6:28 오후

      You are not wrong, it would be useful. Unfortunately, the performance cost of storing the string version of the GUID is non-trivial. In my tests it was allocating quite a lot of memory every time it did the conversion. We don’t really have a great way of customizing the debug inspector either, so it would need to be stored at Editor time.
      While it might certainly be worth it, I wasn’t willing to sacrifice that much memory and performance in the Editor.

      1. Interesting, and yeah it make sense. It’s not worth it to sacrifice editor performance for this. Well at least for now we can use text editor to work with GUID. I hope in the future there’s a solution for this, having a built in tools to work with GUID directly are very helpfull when dealing with broken assets :D

  5. matthew radford

    7월 20, 2018 5:43 오후

    This is great. Fantastic effort! Glad you’ve got a blog post covering the issue. But this is a flaw of Unity. Can we not push to just never create duplicate GUIDs and allow referencing between scenes? I know its hard but If it’s a feature tons of developers are requesting (like me) I personally would love to see the problem solved elegantly and officially.

    1. Lenard Denisz Pop

      7월 24, 2018 10:26 오후

      Something really cool

  6. How about instantiating GameObjects into another scene? (Not the current or active one)

      1. The doc says you can only move root objects to another scene, so that idea of instantiating everything once in a separate scene should work.

  7. Would be nice to have a solution where we can cross-reference monobehaviours in unity events and select public methods in the property drawer of the events.

  8. Was there any particular reason not to use the built-in Object.GetInstanceID? You get pretty unique ids, regardless of the object being an asset, prefab or an instance.

    1. GetInstanceID is not persistent. restart the editor and it may be different

    2. I dont think they are persistent, next time you start up they will differ.

  9. @Nevermind Sounds very similar to our project. We use that pattern for the exact same reasons. And yes, asset duplication is the biggest headache to manage, because designers will often duplicate an existing asset as a starting point for a new one, for productivity reasons. Duplication detection would help, or alternatively if I could rebind Ctrl+D hotkey to custom logic I could account for new guid creation that way.

    1. Also, is there a potential for overlap here with the upcoming Addressables? That system also allows for assigning unique IDs to assets, no? I’m not sure about the scene management side of Addressables though.

      1. Will Armstrong

        7월 19, 2018 8:24 오후

        Not really. The Addressables system only works with assets on disk, which we already have Unique IDs for. This is for GameObjects in scene, a completely different domain.
        Addressables is going to abstract out how you currently work with Assets, so you won’t be worrying about if a given asset on disk is in an AssetBundle, just hard referenced, or coming from somewhere else.

      2. Addressables are for assets. this is for scene objects

        1. Right, but what I’m saying here is that the usefulness of the home brew GUID pattern goes beyond scene management, and this technique described in the blog won’t really replace it unless it also addresses assets. Both this approach and Addressables are ultimately using persistent unique IDs to refer to objects, and it would be nice to have one single consistent solution that addresses both sides of the problem.

  10. Anthony Rosenbaum

    7월 19, 2018 7:44 오후

    Definitely add to Package Manager, I had to do something similar by manipulating ID’s and the AssetDatabase

  11. We have a pretty similar system in our project. We do not use it for cross-scene references, but rather to save references to scene objects in saved games and in ScriptableObject assets.

    We do not use ISerializationCallbackReciever, but rather directly save the stringified version of the guid. It takes a bit more memory, but that does not really matter in practice, and the code is much simpler. In our experience, the system is not as robust as we wanted – it’s too easy to inadvertently change the guid on an object and break all the links to it. It’s not bad, though, we have probably around 100k guid-ed objects in our scenes and broken links are rare. Still, I really wish there was a better way to detect object duplication than “the guid is already in use so that’s probably a duplication or something.”

    I’ve also noticed one thing in your code that shouldn’t be there: some properties in GuidReference hidden behind #if UNITY_EDITOR. While this seems like a good idea, in practice Unity would serialize assets with UNITY_EDITOR defined even when building a standalone player – but the code for the player would not have these fields, and would throw errors trying to deserialize its own assets.

    1. Will Armstrong

      7월 19, 2018 7:19 오후

      Thanks for your feedback! Sounds like you have a way that works well for what you need.
      There are certainly lots of different ways to do this same thing. I found the .ToString() function on a GUID to be far too costly for my purposes compared to using the byte[].
      As of 2017.4 I am not seeing any issues in a built player finding fields it can’t DeSerialize. Everything seems to be working just fine.

    2. Just out of curiousity, since I’m currently building up essentially the same system: How do you end up checking for duplicates? All ideas I have amount to having a file containing all GUIDs that have been already ‘issued’ and checking against that. Another idea was to somehow try and use the timestamp of the file creation, but that likely won’t play nicely with version control.

      1. Will Armstrong

        7월 25, 2018 6:31 오후

        Chris –
        You only need to worry about Duplicating a GUID through copying, loading the same scene twice, serializing it into a prefab, or other workflow issue like that.
        Getting a system guid is ‘guaranteed’ unique.
        “The CoCreateGuid function calls the RPC function UuidCreate, which creates a GUID, a globally unique 128-bit integer. Use CoCreateGuid when you need an absolutely unique number that you will use as a persistent identifier in a distributed environment.To a very high degree of certainty, this function returns a unique value – no other invocation, on the same or any other system (networked or not), should return the same value.”
        from : https://docs.microsoft.com/en-us/windows/desktop/api/combaseapi/nf-combaseapi-cocreateguid

  12. It’s great that you’re adding this. But you probably ought to think about making your implementation threadsafe as the backing Dictionary isn’t threadsafe.

    1. Will Armstrong

      7월 19, 2018 7:22 오후

      I thought about that, but creating GameObjects / MonoBehaviours is also not threadsafe. If you are in ECS land, all this becomes a bit un-necessary as the ECS system has its own stable id system. If you are dealing with traditional GameObjects you need to be on the Main Thread any-who.

    2. neither is the rest of Unity. except the job system, but you can’t use Dictionary or byte[] in a job.

      could throw an exception if called from another thread though