View in English

  • 메뉴 열기 메뉴 닫기
  • Apple Developer
검색
검색 닫기
  • Apple Developer
  • 뉴스
  • 둘러보기
  • 디자인
  • 개발
  • 배포
  • 지원
  • 계정
페이지에서만 검색

빠른 링크

5 빠른 링크

비디오

메뉴 열기 메뉴 닫기
  • 컬렉션
  • 주제
  • 전체 비디오
  • 소개

더 많은 비디오

스트리밍은 대부분의 브라우저와
Developer 앱에서 사용할 수 있습니다.

  • 개요
  • 자막 전문
  • 코드
  • RealityKit 디버거 자세히 알아보기

    새로운 RealityKit 디버거를 소개합니다. 공간 앱의 엔티티 계층을 검사하고, 독립 변환을 디버깅하고, 누락된 엔티티를 찾고, 코드에서 시스템에 문제를 일으키는 부분을 파악하기 위해 RealityKit 디버거를 활용하는 법을 알아보세요.

    챕터

    • 0:00 - Introduction
    • 0:23 - Agenda
    • 0:53 - Prepare for the Journey
    • 1:41 - Meet the RealityKit debugger
    • 2:40 - Transform the BOTanist
    • 4:02 - Traverse hierarchy issues
    • 7:20 - Address bad behaviors
    • 10:52 - Find what's missing
    • 18:44 - Embrace uniqueness
    • 22:34 - Wrap up

    리소스

    • Forum: Developer Tools & Services
      • HD 비디오
      • SD 비디오

    관련 비디오

    WWDC24

    • iOS, macOS, visionOS용 RealityKit API 알아보기
  • 다운로드

    안녕하세요, 저는 Jeremiah입니다 저는 여러분이 멋진 공간 앱과 게임을 제작할 수 있도록 개발자 도구를 만듭니다 오늘은 RealityKit 앱에서 흔히 발생할 수 있는 여러 버그에 대해 이야기하고 그 과정에서 RealityKit 디버거도 소개해 드리겠습니다 버그를 잡는 새로운 도구죠 먼저 RealityKit 디버거를 간단하게 살펴보겠습니다 그런 다음 디버거로 앱을 분석하고 버그를 찾아보겠습니다 엔티티 계층 구조를 살펴보고 예정되지 않은 변환을 찾아 시스템의 불량한 동작을 컴포넌트 내의 실수를 확인하여 해결할 겁니다 빠뜨린 항목을 찾아 렌더링 실수를 해결하고 마지막으로는 앱의 특성에 맞게 RealityKit 디버거를 활용하는 팁과 요령을 공유하겠습니다 준비 되셨나요? 그럼 시작하죠! RealityKit을 사용하면 인상적인 3D 앱을 제작해 iOS, macOS, visionOS에 배포할 수 있습니다 귀여운 로봇이 식물을 돌보는 이 BOT-anist 샘플도 그런 앱이라 할 수 있죠 하지만 커다란 가방을 들고 하루 종일 바쁘게 일하려면 얼마나 힘들까요! 가끔은 로봇도 편하게 휴식을 취할 공간이 필요할 겁니다 친구도 만나고 고급 윤활유도 마시고 춤도 추고, 로봇이니까 증기를 내뿜으며 기분도 좀 풀어야죠 이번 세션에서는 BOT-anist 샘플을 확장해 휴식 모드를 추가하겠습니다 로봇의 정원을 클럽으로 바꾸는 프로토타입을 작업하고 있습니다 하지만 아직 개장할 준비는 하지 못한 상태입니다 아직 해결하지 못한 버그가 많거든요 RealityKit 디버거를 사용하여 버그를 잡아 보겠습니다

    RealityKit 디버거는 실행 중인 앱의 3D 스냅샷을 포착하고 Xcode에서 불러와 살펴볼 수 있습니다 화면 하단의 디버그 영역에서 ‘Capture Entity Hierarchy’ 버튼을 클릭하여 시작합니다

    스냅샷이 완료되면 포착된 RealityKit 장면이 왼쪽의 디버그 내비게이터에 표시됩니다

    장면을 선택하면 옆의 창에 엔티티 계층 구조가 표시됩니다

    3D 뷰포트의 콘텐츠도 재구성되죠

    계층 구조 또는 뷰포트에서 엔티티를 선택하면 엔티티의 속성과 함께 엔티티 컴포넌트의 속성이 오른쪽 인스펙터에 표시됩니다

    현재 선택한 계층 구조의 통계가 표시되는 인스펙터 창도 볼 수 있습니다 RealityKit 디버거는 기존 Xcode 워크플로에 통합되어 더 생산적이고 즐거운 3D 개발 환경을 구현하는 새로운 인사이트를 제공합니다 이제 새로운 도구로 이 클럽을 수리해 보겠습니다

    샘플을 변환하기 위한 코드 패치가 꽤 많으므로 제 설명을 계속 따라오려면 ClubView Swift 파일을 다운로드하고 Xcode 프로젝트에 드래그한 다음 대상에 추가하세요 그런 다음 두 가지를 수정해야 합니다 먼저 클럽에 필요한 새로운 볼류메트릭 장면을 정의하여 BOTanistApp의 body에 추가합니다

    그리고 클럽을 여는 버튼이 필요합니다 저는 RobotView의 body에 추가했습니다 기존 ‘Start Planting’ 버튼 옆에 추가했습니다 이제 앱을 빌드해 visionOS 시뮬레이터에서 실행할 수 있습니다

    앱을 실행하면 로봇 뷰에서 시작합니다 로봇을 생성하는 대신 디스코 볼을 클릭해 클럽에 입장하겠습니다

    카메라를 제어하여 더 가까이 갈 수 있습니다

    장면에 있던 기존 애셋을 많이 수정했습니다 이를테면 화분은 순간이동기로 바꿨죠 새로 생성한 엔티티도 있습니다 디스코 볼도 새로 만든 거죠 지금 모습은 조금 이상하네요 장면을 분석해 이유를 알아보겠습니다

    Xcode에서 디버그 영역의 버튼을 클릭해 RealityKit Debugger를 실행합니다

    자세히 살펴보기 전에 잠시 변환 계층 구조가 어떻게 작동하는지 생각해 보겠습니다 3D 장면에 콘텐츠를 배치할 때는 위치, 방향, 스케일을 설정합니다 가장 흔하게 발생하는 버그는 설정한 위치에 콘텐츠가 표시되지 않는 것입니다 이 문제가 발생하는 이유는 엔티티가 배치되는 최종 위치는 사실 엔티티 자신의 변환과 모든 부모 엔티티 변환의 조합에 의해 결정되기 때문입니다 이러한 이유로, 다른 엔티티에만 적용하려던 변환이 이 엔티티에도 적용되는 결과가 자주 발생합니다 디스코 볼에도 바로 이러한 문제가 있을 겁니다 RealityKit 디버거로 확인해 보겠습니다

    디버그 내비게이터에서 장면을 펼치고 RealityKit 콘텐츠를 선택합니다 그러면 디버거에서 장면이 열립니다

    이제 내비게이터와 디버그 영역을 숨겨 작업 공간을 확보할 수 있습니다

    뷰포트에서 디스코 볼을 이중 클릭해 선택하고 중앙으로 이동합니다

    메인 뷰포트에는 엔티티가 장면에 보이는 그대로 표시됩니다 부모 엔티티의 변환도 모두 적용된 후의 모습이죠 뷰포트에서 엔티티를 선택하면 엔티티 계층 구조와 엔티티 인스펙터에서도 선택됩니다 현재 Outline이라는 이름의 엔티티를 선택했습니다 디스코 볼의 윤곽선을 표시하는 엔티티입니다 엔티티 인스펙터에 더 작은 보조 뷰포트가 있습니다 이 뷰포트에서는 변환이 적용되지 않은 엔티티의 모델 컴포넌트를 미리 볼 수 있습니다 미리보기의 Outline 엔티티는 왜곡되어 있지 않으므로 문제의 원인은 메시가 아닙니다 또한 미리보기 창 아래에서 확인할 수 있는 이 엔티티에 대한 트랜스폼 컴포넌트의 스케일 값이 모두 1이므로 엔티티 자체가 왜곡되는 것도 아닙니다 아마도 부모로부터 상속받은 변환이 문제의 원인일 것입니다 계층 구조를 올라가며 어떤 변환이 문제를 일으키는지 찾아보겠습니다 엔티티 계층 구조에서 부모인 Background 엔티티를 클릭합니다 인스펙터 창의 미리보기 뷰포트와 트랜스폼 컴포넌트에서 이 엔티티도 왜곡을 일으키는 원인이 아니라는 걸 알 수 있습니다 계층 구조에서 이 엔티티의 부모인 Support 엔티티를 클릭하겠습니다

    인스펙터 창에서 Support 엔티티 트랜스폼 컴포넌트의 Y축 스케일 값이 매우 큰 것을 볼 수 있습니다 원하는 형태를 만들기 위해 스케일을 조절한 것에는 문제가 없지만 여기서 실수는 의도치 않게 자식들에게도 이 스케일이 적용된다는 사실입니다

    Support 및 Background 엔티티를 부모 자식이 아닌 형제자매로 바꾸면 문제가 해결될 겁니다

    장면에서는 여전히 연결된 것처럼 보이겠지만 Support의 변환이 디스코 볼에 더 이상 영향을 주지 않습니다 앱을 다시 실행해 클럽에 입장하고 결과를 확인해 보겠습니다

    RealityKit 디버거를 사용해 장면의 계층 구조를 이동하며 엔티티가 왜곡되는 문제의 원인인 변환을 찾아내고 부모를 변경해 해결했습니다 이제 구체로 표시되네요 클럽의 바뀐 모습이 점점 보이고 있습니다 이제 이 객체들에 생동감을 부여할 차례입니다 RealityKit은 엔티티 컴포넌트 시스템 줄여서 ECS를 사용해 객체와 동작을 관리합니다 엔티티에 특성을 부여하기 위해 데이터를 담을 수 있는 다양한 컴포넌트를 할당합니다 그런 다음 특정 컴포넌트가 있는 엔티티를 업데이트하는 시스템을 만듭니다 엔티티의 컴포넌트가 올바르게 구성되지 않았거나 누락된 경우 시스템의 동작을 예측할 수 없습니다 클럽으로 돌아가 예시를 살펴보겠습니다

    클럽으로 변환하며 모든 화분을 순간이동기로 바꿨습니다 순간이동 시스템이 로봇을 생성해야 하는데 아무도 보이지 않네요 디버거를 통해 이유를 알아보겠습니다

    먼저 순간이동 시스템의 작동 방식을 설명하겠습니다

    시스템은 제어 센터 컴포넌트에 데이터를 저장합니다 장면이 업데이트될 때마다 시스템이 카운트다운 값을 줄입니다 카운트다운 값이 0에 도달하면 장면 내 순간이동기 컴포넌트를 가진 모든 엔티티를 찾아 무작위로 하나를 선택하고 그 위치에 로봇을 생성합니다 카운터가 재설정되고 클럽의 정원이 모두 찰 때까지 프로세스가 반복됩니다 디버거로 전환하여 컴포넌트를 살펴보겠습니다

    어쩌면 순간이동기 엔티티에 순간이동기 컴포넌트를 할당하지 않았을 수 있습니다 그렇다면 순간이동기를 찾을 수 없으므로 로봇이 생성될 곳이 없습니다 이 사항은 RealityKit 디버거로 쉽게 확인할 수 있습니다 엔티티 계층 구조에서 BOT Club과 Teleportation Center를 펼치고 첫 번째 순간이동기를 이중 클릭합니다

    엔티티 인스펙터를 보면 Teleporter 컴포넌트가 할당되어 있습니다 나머지 2개의 순간이동기도 확인해 보겠습니다

    전부 순간이동기 컴포넌트가 할당되어 있네요 문제의 원인은 제어 센터일지도 모르겠네요 계층 구조에서 부모 엔티티 Teleportation Center를 선택합니다

    제어 센터 컴포넌트의 속성을 살펴보겠습니다

    카운트다운 값이 눈에 띄네요 초기 값과 일치합니다 RealityKit 디버거는 일시정지한 순간 앱의 상태를 포착하므로 시스템이 제대로 동작한다면 이 값이 변했어야 합니다 무슨 이유인지 Control Center 컴포넌트가 업데이트되지 않네요 코드를 살펴보고 이유를 찾아보겠습니다

    업데이트될 때마다 제어 센터 컴포넌트에서 카운트다운 값을 감소시킵니다 그런 다음 이런, 업데이트된 컴포넌트를 엔티티에 다시 저장한다고 말하려고 했으나 그 단계를 누락했네요 누락된 단계를 추가하고 앱을 다시 실행하겠습니다 흔히 발생하는 실수죠 수정된 컴포넌트를 엔티티에 다시 할당해줘야 합니다 시뮬레이터로 돌아가 문제가 해결되었는지 보겠습니다

    RealityKit 디버거를 사용하여 잘못 작동하는 시스템을 찾아 문제를 해결했습니다 이제 클럽으로 돌아가 손님이 맞이해 보겠습니다 손님들이 빨리 오면 좋겠네요 여기 임대료가 어마어마하게 비싸거든요 보세요 첫 손님이 왔습니다

    이제 로봇이 순간이동으로 오는데 아직 문제가 있습니다 카운터에 윤활유가 든 병이 있어야 합니다만 보이지 않네요 다시 구비했던 것으로 아는데요 빨리 찾지 못하면 로봇들이 춤추는 걸 멈추고 클럽이 제대로 굴러가지 않을 겁니다 아마 렌더러 때문에 숨겨진 것 같습니다 설명해 드리겠습니다 RealityKit 같은 3D 렌더러는 성능을 높이는 방법의 하나로 일부 물체만 선택하여 렌더링해 시간을 절약합니다 예를 들어 너무 멀리 있거나 너무 가까이 있거나 다른 콘텐츠에 가려지거나 불투명도가 너무 낮게 설정되거나 ARKit 앵커를 찾고 있거나 애셋이 완전히 누락되는 등 이를 비롯한 다양한 경우에 콘텐츠가 렌더링되지 않습니다 그리고 이유를 파악하려면 보통 소거법을 사용해야 합니다

    Reality Composer Pro를 사용하여 애셋을 준비, 테스트, 패키징하면 문제 방지에 도움이 됩니다 하지만 콘텐츠가 계속 유실된다면 RealityKit 디버거로 원인을 찾을 수 있습니다 디버거로 전환하여 병이 표시되지 않는 문제를 해결해 보겠습니다

    뷰포트에서 카운터를 이중 클릭하고 카메라를 조정하여 가까이에서 보겠습니다

    좋습니다 카운터에 최고급 윤활유가 담긴 녹색 병 9개가 있어야 하지만 지금은 하나만 표시되며 심지어 올바르게 렌더링되지 않습니다 원인을 알아보겠습니다 엔티티 계층 구조에서 Counter와 BottleGroup을 펼치고 첫 번째 병을 선택합니다

    엔티티 선택 시 윤곽선은 엔티티가 다른 엔티티에 가려진 경우에도 표시됩니다 여기서는 이 병이 이곳에 있다고 표시되네요 하지만 카운터 아래에 있습니다 인스펙터에서도 확인할 수 있습니다 병의 Transform 컴포넌트의 Y축 값이 음수니까요 간단하게 수정할 수 있으니 나중에 고쳐 보겠습니다 지금은 계속해서 다음 문제를 조사해 보겠습니다

    계층 구조에서 병 2를 선택합니다

    선택해도 윤곽선이 보이지 않습니다 계층 구조에서 엔티티를 이중 클릭하면 카메라가 해당 엔티티에 초점을 맞춥니다

    상당히 멀리 있네요 너무 먼 나머지 장면의 경계를 벗어났습니다 노란색 상자가 경계입니다 이 때문에 렌더러가 이 엔티티를 잘라내고 표시하지 않는 겁니다 첫 번째 병과 마찬가지로 트랜스폼을 수정하면 해결됩니다

    계속하겠습니다 계층 구조에서 세 번째 병을 이중 클릭해 선택하고 초점을 맞춥니다

    와, 병이 너무 커서 장면이 병 속에 있네요 메시를 구성하는 삼각형은 일반적으로 한쪽 면에서만 보입니다 따라서 메시 내부에서는 아예 표시되지 않는 경우가 많습니다 이 객체의 스케일을 축소하면 문제가 해결됩니다 카운터에서 상당히 멀어졌네요 이제 계층 구조에서 네 번째 병을 이중 클릭해 다시 가까이 가겠습니다

    계층 구조에서 보면 병 4 옆에 아이콘이 있습니다 엔티티가 활성화되지 않았음을 알려 주는 아이콘입니다 활성화되지 않은 엔티티는 렌더링되지 않습니다 컴포넌트를 살펴보면 문제의 원인을 알 수 있습니다

    이전 병들과는 달리 여기에는 OutOfStock 컴포넌트가 있네요 저는 이 컴포넌트를 사용하여 재고가 없는 항목에 태그를 달고 숨깁니다 따라서 이 병은 의도적으로 렌더링되지 않았습니다

    다섯 번째 병으로 넘어가겠습니다

    이 병의 인스펙터에도 예상치 못한 컴포넌트가 있네요 Anchoring 컴포넌트입니다 초기 프로토타입에서 제가 클럽에 디너 서비스를 추가하고 싶어 추가한 오래된 코드입니다 해당 기능을 제거할 때 컴포넌트를 삭제해야 했으나 그만 잊었네요 Anchoring 컴포넌트가 있지만 장면에 해당하는 ARKit 앵커가 없으면 엔티티가 렌더링되지 않습니다

    여섯 번째 병으로 넘어가겠습니다

    뷰포트에 선택 시 윤곽선은 표시되지 않고 축만 표시됩니다 이건 엔티티에 모델 컴포넌트가 없다는 것을 의미합니다 인스펙터에서도 확인할 수 있습니다 로딩에 실패했거나 다른 엔티티에 잘못 할당했을 수도 있습니다 여기서는 정확히 알 수 없으니 나중에 코드를 확인해야 합니다 하지만 문제의 원인이 무엇이고 어디를 살펴봐야 하는지는 확인했습니다

    일곱 번째 병으로 넘어갑니다

    메인 뷰포트에서도 보이지 않고 미리보기 뷰포트에서도 안 보이네요 따라서 모델 컴포넌트의 문제일 겁니다 메인 뷰포트에서 선택하면 정확한 형태로 보이는 것 같으므로 메시는 올바르게 설정되었지만 머티리얼에 문제가 있는 것 같습니다 모델 컴포넌트에서 머티리얼을 펼치고 속성을 살펴보겠습니다

    이 머티리얼은 반투명으로 설정되어 있지만 불투명도 임계값이 1이네요 제가 잘못 설정하여 모델에서 불투명도가 1보다 작은 부분은 엔진이 렌더링하지 않도록 설정하고 모델의 모든 부분에서 불투명도를 1 미만으로 설정했습니다 그 결과 병 전체가 표시되지 않았습니다

    다음으로 8번 병은 사실 보이기는 합니다 적어도 일부는 표시되죠 인스펙터에서 눈에 띄는 문제는 보이지 않습니다 이러한 경우 RealityKit 디버거를 사용하면 다양한 추가 표시 방법으로 자칫하면 놓칠 수 있는 문제를 발견할 수 있습니다

    미리보기 뷰포트에서 맨 오른쪽 드롭다운 메뉴로 렌더링 모드를 변경합니다 첫 번째 옵션을 선택하여 노멀 값을 표시해 보겠습니다 객체의 각 위치에서 노멀 값에 따른 색상을 표시해 줍니다 노멀 값은 표면이 바라보는 방향을 나타내며 조명 및 렌더링 계산에 사용됩니다 처음에는 어려워 보일 수 있지만 이 엔티티와 정상적으로 설정된 1번 병을 비교하면

    메시에 문제가 있다는 것을 알 수 있습니다

    임포트한 애셋에서 흔히 발생하는 오류이며 3D 콘텐츠 생성 도구에서 해결해야 합니다

    이제 하나 남았습니다 9번 병을 선택하겠습니다 아, 9번 병은 없네요 장면에 추가하는 걸 잊었나 봅니다 계층 창 하단의 필터 바를 사용하여 확인해 보겠습니다 이름에 BT가 포함된 엔티티만 표시해 보겠습니다

    역시 장면에 추가하지 않았네요

    여러 문제를 빠르게 살펴봤고 이제 요약해 설명하겠습니다 보이지 않던 처음 몇 개의 병은 트랜스폼 값에 따라 가려지거나 잘리거나 병 안에 있었습니다 활성화되지 않아 숨겨진 병도 있었고 앵커가 없어 숨겨진 병도 있었습니다 메시가 잘못된 병과 메시가 누락된 병도 있고 머티리얼이 잘못 설정되어 숨겨진 병도 있었습니다 실수로 장면에 추가하지 않은 병도 있었습니다 제가 작성한 병 생성 코드는 복사 후 붙여넣기로 인한 오류가 많았습니다

    따라서 하나의 생성 루프로 코드를 대체해 보겠습니다 코드에서 직접 3D 애셋을 배치하고 설정하는 것은 어렵습니다 따라서 가능하다면 장면 레이아웃을 Reality Composer Pro에서 준비해 보세요 앱을 다시 실행해 클럽에 입장하겠습니다

    디버거의 다양한 도구를 사용하여 누락된 병을 찾았습니다 이제 카운터가 윤활유로 가득 채워졌으므로 안심하고 다음으로 넘어가겠습니다

    문제를 발생시키는 변환부터 잘못 구성된 컴포넌트 렌더링 실수까지 앱을 제작할 때 흔히 발생할 수 있는 다양한 문제를 살펴봤습니다 하지만 앱은 고유한 부분이 많고 복잡성이 증가함에 따라 디버깅의 어려움도 함께 증가합니다 하지만 ECS의 유연성을 활용하여 RealityKit 디버거 경험을 맞춤화할 수 있습니다 어떻게 하는지 보여 드리겠습니다 저희의 Dance 시스템입니다 보이지 않는 여러 Attractor 엔티티에 적용됩니다 댄스 플로어 위에 배치되어 있죠 새로운 손님이 클럽에 순간이동하면 비어 있는 Attractor가 할당되며 업데이트 때마다 Attractor에 가깝게 이동합니다 로봇이 Attractor 위치에 도달하면 작동이 시작되어 춤을 추기 시작합니다 하지만 어떤 문제를 보셨을 겁니다 로봇이 순간이동 후 가만히 서 있기만 하고 어트랙터 쪽으로 움직이지 않죠 이 시스템에 RealityKit 디버그 기능을 내장해 보겠습니다

    먼저 기본 모델 엔티티를 장면에 추가하여 보이지 않는 어트랙터를 표시합니다 각각 커스텀 컴포넌트를 할당해 인스펙터에서 보고 싶은 값을 저장하여 어트랙터 상태 등을 표시합니다 그리고 이 모두를 보이지 않는 한 엔티티 아래에 묶어 플레이하는 동안 표시되지 않도록 합니다 또한 이 부모 엔티티에도 커스텀 컴포넌트를 할당해 시스템 전체에 대한 정보를 표시합니다

    간소화한 버전의 코드입니다 ClubView Swift 파일에 전체 버전이 포함되어 있습니다 뭔가 대단해 보이지만 RealityKit의 기본 기능만 사용합니다 엔티티, 컴포넌트, 시스템만 사용하죠 유일하게 이상해 보이는 점은 모든 코드를 디버그 컴파일 블록 안에 배치했다는 것입니다 이렇게 하면 릴리즈 앱에서는 코드가 컴파일되지 않습니다 따라서 성능에 미칠 영향을 걱정하지 않아도 됩니다 새 디버그 시스템을 구현했으니 앱을 다시 실행하고 로봇이 생성되는 걸 기다렸다가 디버거를 살펴보겠습니다

    계층 구조에서 ‘ Dance System’ 엔티티를 찾아 선택합니다 디버그 컴포넌트의 속성이 인스펙터에 표시됩니다 RealityKit 디버거는 앱에서 일반적으로 사용하는 대부분의 유형을 표시할 수 있습니다 이를 활용하여 간단하게 카운터를 표시하거나 더욱 창의적으로 UIImage 프로퍼티로 저장한 Swift 차트를 표시할 수도 있습니다 새 디버그 컴포넌트로 댄스 시스템의 문제를 파악할 수 있습니다 모든 어트랙터가 로봇을 끌어당기는 상태입니다만 이럴 수는 없죠 이 문제는 시각화를 이용해 관찰할 수도 있습니다 엔티티 계층 구조에서 ‘ Dance System’ 엔티티를 우클릭하여 빠른 메뉴를 열고 표시 여부를 토글합니다

    각 어트랙터의 상태가 시각화되어 표시됩니다 실제로 전부 주황색이네요 끌어당기는 상태를 나타내는 색상입니다 한 어트랙터가 대상으로 삼을 수 있는 로봇은 한 개뿐입니다 로봇이 순간이동하면 Newcomer 컴포넌트 태그가 달리고 어트랙터의 대상이 되면 해당 태그가 제거됩니다 시각화된 디버그 엔티티 하나를 선택하여 디버그 컴포넌트를 살펴보겠습니다

    컴포넌트에 대상 로봇의 레퍼런스를 저장하도록 설정했습니다 RealityKit 디버거가 이를 링크로 변환해 줍니다 클릭해 대상을 찾아보겠습니다

    로봇의 컴포넌트를 보니 문제의 원인을 알겠네요 Newcomer 컴포넌트가 아직도 있습니다 처음 대상이 되었을 때 제거되었어야 하는데 말이죠 제거되지 않으니 모든 어트랙터가 이 로봇을 찾아 끌어당기려고 하므로 로봇은 선택지가 너무 많아 움직이지 못합니다 문제를 파악했으니 코드에서 해결해 보겠습니다

    댄스 시스템에서 대상으로 설정되면 Newcomer 컴포넌트를 제거해야 하는데 그렇지 않았네요 제거하는 코드를 추가하고 앱을 다시 실행하겠습니다

    이런 유형의 버그는 해결하기는 쉬워도 발견하기는 어려울 수 있습니다 시스템의 복잡성이 증가하고 앱이 확장될수록 더 그렇죠 하지만 동일한 시스템을 활용하여 시각화하고 커스텀 컴포넌트로 인스펙터 속성을 추가하여 저희가 개발하면서 겪는 경험을 즐겁게 만들어 플레이어를 위해 제작한 경험이나 로봇들만큼 즐길 수 있습니다 드디어 클럽을 개장하고 성공을 만끽할 수 있겠네요

    RealityKit 디버거의 도움을 받아 엔티티 계층 구조와 컴포넌트의 문제를 파악하고 해결했습니다 또한 ECS의 유연성을 활용하여 시각화 기능과 커스텀 인스펙터 속성을 추가해 봤습니다 이를 통해 앱의 고유한 부분을 원활하게 디버깅할 수 있었습니다 이 세션에서는 많은 내용을 다루었으니 이제 로봇 친구들처럼 휴식을 취할 시간입니다

    • 2:45 - ClubView

      /*
      Abstract:
      The full club patch. SwiftUI view, state, extensions and helpers.
      */
      
      import SwiftUI
      import RealityKit
      import OSLog
      import BOTanistAssets
      import Combine
      import Charts
      
      struct ClubView: View {
          @State var state = ClubViewState()
          
          var body: some View {
              ZStack {
                  RealityView { content in
                      state.loadEnvironment()
                      
                      state.rootEntity.scale = SIMD3<Float>(repeating: 0.5)
                      
                      content.add(state.rootEntity)
                  } update: { updateContent in
                      if !state.doorSupervisor.doorsOpen {
                          state.transformIntoClub(content: updateContent)
                      }
                  }
              }
          }
      }
      
      @Observable
      @MainActor
      final public class ClubViewState: Sendable {
          let rootEntity = Entity()
          
          private var loadedEnvironmentRoot: Entity?
          private var robotRevolutionController: Entity?
          private var host: Entity?
          
          private(set) var doorSupervisor: DoorSupervisor {
              get {
                  rootEntity.components[DoorSupervisor.self]!
              } set {
                  rootEntity.components[DoorSupervisor.self] = newValue
              }
          }
          
          init() {
              RevolvingSystem.registerSystem()
              HoverSystem.registerSystem()
              TeleportationSystem.registerSystem()
              DanceMotivationSystem.registerSystem()
              
              rootEntity.name = "The B0T Club"
              rootEntity.components[DoorSupervisor.self] = DoorSupervisor(capacity: 9)
          }
          
          /// Load the existing garden assets
          func loadEnvironment() {
              guard loadedEnvironmentRoot == nil else {
                  return
              }
              
              if let environment = try? Entity.load(named: "scenes/volume", in: BOTanistAssetsBundle) {
                  environment.name = "Environment"
                  self.loadedEnvironmentRoot = environment
                  
                  rootEntity.addChild(environment)
              }
          }
          
          /// Renovate the loaded environment to build our club
          func transformIntoClub(content: RealityViewContent) {
              guard !doorSupervisor.doorsOpen else {
                  return
              }
              
              // Build a teleportation center and use it to spawn robots
              addTeleportationCenterToTheClub()
              
              // Haphazardly clean up the space by hiding anything un-club-like
              hideStuffInTheEnvironment()
              
              // Polish that floor and add some spin
              addRevolvingDanceFloorToTheClub()
              
              // Keep the robots moving in an orderly fashion
              addRobotRevolutionControllerToTheClub()
              
              // Install some attractors to entice robots to the dance floor
              addDanceFloorAttractors()
              
              // Set the mood
              addSpotlightsToTheClub()
              
              // Stock up on oil to keep the moves smooth
              addCounterToTheClub()
              
              // And add a huge Disco Ball, because...
              addDiscoBallToTheClub()
              
              // Let the party begin
              openDoors()
          }
          
          /// Construct a Teleportation Center and add it to the Club's root entity
          private func addTeleportationCenterToTheClub() {
              let teleportationCenter = Entity()
              teleportationCenter.name = "Teleportation Center"
              rootEntity.addChild(teleportationCenter)
              
              // Liven up the planters to look more like teleporters
              let positions: [SIMD3<Float>] = [[0.128, 0, 0.14], [-0.255, 0, 0.23], [0.05, 0, -0.17]]
              let colors: [(UIColor, UIColor)] = [(.green, .yellow), (.magenta, .purple), (.cyan, .blue)]
              for index in 0...2 {
                  if let teleporter = rejigPlanter(identifier: String(index + 1), position: positions[index], colors: colors[index]) {
                      teleportationCenter.addChild(teleporter)
                  }
              }
              
              // Create a Control Center and provide a closure to handle robot spawning
              let teleportationControlCenter = ControlCenterComponent(
                  initialValue: 10,
                  interval: 5,
                  rootEntity: rootEntity) { teleporter in
                      self.spawnRobot(from: teleporter)
                      self.countVisitor()
                      
                      // Have the host say hello
                      if let hostCharacter = self.host?.components[AutomatonControl.self]?.character {
                          hostCharacter.transitionToAndPlayAnimation(.idle)
                          hostCharacter.transitionToAndPlayAnimation(.wave)
                      }
              }
              
              // Assign the new control center component to the teleportation center entity
              teleportationCenter.components[ControlCenterComponent.self] = teleportationControlCenter
          }
          
          /// Transforms the visuals of the planters to look more teleporter-y
          private func rejigPlanter(identifier: String, position: SIMD3<Float>, colors: (UIColor, UIColor)) -> Entity? {
              if let rim = rootEntity.findEntity(named: "heroPlanter_rim_\(identifier)"),
                 let dirt = rootEntity.findEntity(named: "dirt_hero_\(identifier)"),
                 let rimModelComponent = rim.components[ModelComponent.self],
                 var dirtModelComponent = dirt.components[ModelComponent.self] {
                  // Apply the luminous material from the rims to the dirt (trust me it will look cool).
                  dirtModelComponent.materials = rimModelComponent.materials
                  dirt.components[OpacityComponent.self] = OpacityComponent(opacity: 0.7)
                  dirt.components[ModelComponent.self] = dirtModelComponent
              }
              
              // Make a teleporter container entity
              let teleporter = Entity()
              teleporter.name = "Teleporter-T\(identifier)"
              teleporter.position = position
              teleporter.components[TeleporterComponent.self] = TeleporterComponent()
              
              // Add a particle emitter
              let radius: Float = 0.035
              var particleEmitter = ParticleEmitterComponent.Presets.teleporter
              particleEmitter.emitterShapeSize = .init(repeating: radius)
              particleEmitter.mainEmitter.color = .constant(.random(a: colors.0, b: colors.1))
              
              let particleEntity = Entity()
              particleEntity.orientation = .init(angle: -.pi / 2, axis: [1, 0, 0])
              particleEntity.components[ParticleEmitterComponent.self] = particleEmitter
              particleEntity.name = "Photons"
              particleEntity.scale = .init(repeating: 1)
              teleporter.addChild(particleEntity)
              
      #if DEBUG
              // Add a debug marker in case we want to visually inspect this in the RealityKit Debugger
              teleporter.addDebugMarker(radius: radius, color: colors.0)
      #endif
              
              return teleporter
          }
          
          /// adds a random robot to the club root, positioned at the provided point
          private func spawnRobot(from spawnPoint: Entity) {
              guard let robotCharacter = randomRobot() else {
                  logger.error("Robot creation malfunction 🤖💥")
                  return
              }
              
              let guest = Entity()
              
              guest.addChild(robotCharacter.characterParent)
              guest.position = spawnPoint.position(relativeTo: rootEntity)
              guest.components[Newcomer.self] = Newcomer()
              guest.components[AutomatonControl.self] = AutomatonControl(character: robotCharacter)
              
              rootEntity.addChild(guest)
              
              // Play a little flashy burst on the particle emitter
              if let particles = spawnPoint.findEntity(named: "Photons") {
                  var component = particles.components[ParticleEmitterComponent.self]
                  component?.burst()
                  particles.components[ParticleEmitterComponent.self] = component
              }
          }
          
          /// misuses AppState as a robot factory - don't try this at home, or do, but don't ship it!
          private func randomRobot() -> RobotCharacter? {
              let robotMaker = AppState()
              
              // Use offsets from the loaded animation rig, with some random parts
              guard let skeleton = robotMaker.robotData.meshes[.body]?.findEntity(named: "rig_grp") as? ModelEntity else {
                  logger.error("Failed to find a robot animation rig... all dancing in cancelled ❌🕺")
                  return nil
              }
              
              robotMaker.randomizeSelectedRobot()
              
              guard let head = robotMaker.robotData.meshes[.head]?.clone(recursive: true),
                    let body = robotMaker.robotData.meshes[.body]?.clone(recursive: true),
                    let backpack = robotMaker.robotData.meshes[.backpack]?.clone(recursive: true) else {
                  fatalError()
              }
              
              let robotCharacter = RobotCharacter(
                  head: head,
                  body: body,
                  backpack: backpack,
                  appState: robotMaker,
                  headOffset: skeleton.pins["head"]?.position,
                  backpackOffset: skeleton.pins["backpack"]?.position
              )
              
              // Pick a random robot name from the sequence
              robotCharacter.characterParent.name = RobotNames.next
              
              // Remove the character controller and animation state, as we'll manually control these
              robotCharacter.characterParent.components[CharacterControllerComponent.self] = nil
              AnimationState.handlers.removeAll()
              
              // The robots are here to chill, so actually, let's put their backpacks in the cloakroom
              backpack.removeFromParent()
              
              // Say Hi
              robotCharacter.transitionToAndPlayAnimation(.wave)
              
              return robotCharacter
          }
          
          /// Update capacity when we have a visitor
          private func countVisitor() {
              var management = self.doorSupervisor
              management.visitorCount += 1
              self.doorSupervisor = management
          }
          
          /// Find and hide a bunch of stuff in the loaded environment
          private func hideStuffInTheEnvironment() {
              // We used the RealityKit Debugger to identify the names of things we want to hide in the club
              ["setDressing", "MovementBoundaries", "planter_side", "planter_Hero", "planter_Hero_1", "planter_Hero_2", "PlantLightGroup",
               "PlantLightGroup_1", "PlantLightGroup_2", "SidePlanterLights", "pipe_2", "pipe_3", "dirt_coffeeBerry_1", "dirt_coffeeBerry_2",
               "dirt_coffeeBerry_3", "dirt_side"].forEach { name in
                  if let entity = rootEntity.findEntity(named: name) {
                      entity.removeFromParent()
                  }
              }
          }
          
          /// Repurpose some existing bits in the environment to create a makeshift revolving dance floor - if it looks like dirt, that's because it is
          private func addRevolvingDanceFloorToTheClub() {
              guard let dirtFloor = loadedEnvironmentRoot?.findEntity(named: "dirt_end") else {
                  return
              }
              
              // Add a revolving container entity
              let revolvingDanceFloor = Entity()
              revolvingDanceFloor.name = "Revolving Dance Floor"
              revolvingDanceFloor.scale = [1, 1, 1]
              revolvingDanceFloor.position = [0, 0.181, 0]
              revolvingDanceFloor.components[RevolvingComponent.self] = RevolvingComponent(relativeTo: rootEntity)
              
              // Polish up the dirt floor
              let geometry = dirtFloor.clone(recursive: false)
              geometry.name = "Dirt Floor"
              geometry.transform = .identity
              geometry.position = [0, 0, 0]
              geometry.scale = dirtFloor.scale(relativeTo: rootEntity)
              
              let polish = geometry.clone(recursive: false)
              polish.name = "Polish Layer"
              polish.position = [0, 0.0004, 0]
              
              if var modelComponent = geometry.components[ModelComponent.self] {
                  var polishedFloorMaterial = PhysicallyBasedMaterial()
                  
                  polishedFloorMaterial.baseColor = .init(tint: .gray)
                  polishedFloorMaterial.roughness = .init(floatLiteral: 0.2)
                  polishedFloorMaterial.metallic = .init(floatLiteral: 0.8)
                  polishedFloorMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5))
                  polishedFloorMaterial.clearcoat = .init(floatLiteral: 0.4)
                  
                  modelComponent.materials = [polishedFloorMaterial]
                  
                  polish.components[ModelComponent.self] = modelComponent
              }
              
              // Add it to the revolving container
              revolvingDanceFloor.addChild(geometry)
              revolvingDanceFloor.addChild(polish)
              
              rootEntity.addChild(revolvingDanceFloor)
          }
          
          /// Creates a revolving container entity to keep robots moving in sync with the dance floor
          private func addRobotRevolutionControllerToTheClub() {
              let robotRevolutionController = Entity()
              robotRevolutionController.name = "Robot Revolution Controller"
              robotRevolutionController.components[RevolvingComponent.self] = RevolvingComponent(relativeTo: rootEntity)
              
              rootEntity.addChild(robotRevolutionController)
              
              self.robotRevolutionController = robotRevolutionController
          }
          
          /// Add invisible attractors to the dance floor to position and control robots
          private func addDanceFloorAttractors() {
              guard let robotRevolutionController else {
                  logger.error("The Robot Revolution Controller is missing 😱")
                  return
              }
              
              // Add a few dance spots on the outside of the club that we know don't obstruct the furniture
              let staticAttractors = Entity()
              staticAttractors.name = "Static Attractors"
              
              let placementRadius: Float = 0.25
              let outerRadius = placementRadius * 0.8
              addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 10), placementRadius: outerRadius, name: "Static-A1", variation: 0)
              addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 90), placementRadius: outerRadius, name: "Static-A2", variation: 0)
              addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 130), placementRadius: outerRadius, name: "Static-A3", variation: 0)
              addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 240), placementRadius: outerRadius, name: "Static-A4", variation: 0)
              addDanceFloorAttractor(to: staticAttractors, angle: Angle2D(degrees: 325), placementRadius: outerRadius, name: "Static-A5", variation: 0)
              
              rootEntity.addChild(staticAttractors)
              
              // The remaining center attractors are on the revolving dance floor and can be more randomly positioned
              let innerRingCapacity = doorSupervisor.capacity - 5
              
              let revolvingAttractors = Entity()
              revolvingAttractors.name = "Revolving Attractors"
              
              addDanceFloorAttractors(to: revolvingAttractors, count: innerRingCapacity, placementRadius: placementRadius * 0.3, namePrefix: "Revolving")
              
              robotRevolutionController.addChild(revolvingAttractors)
              
      #if DEBUG
              // Add some debug visualizations
              let debugRoot = Entity()
              debugRoot.name = "[Debug] Dance System"
              debugRoot.isEnabled = false
              debugRoot.components[DanceSystemDebugComponent.self] = DanceSystemDebugComponent()
              
              rootEntity.addChild(debugRoot)
              
              let allAttractors = Array(staticAttractors.children) + Array(revolvingAttractors.children)
              
              // Create a new visualization for each attractor
              allAttractors.forEach { attractor in
                  if let visualization = Entity.makeDebugMarker(height: 0.08, radius: 0.03, enabled: true) {
                      guard let attractorComponent = attractor.components[AttractorComponent.self] else {
                          return
                      }
                      
                      let debugComponent = AttractorDebugComponent(state: attractorComponent.state, attractor: attractor)
                      
                      visualization.position = [0, 0.04, 0]
                      visualization.components[AttractorDebugComponent.self] = debugComponent
                      debugRoot.addChild(visualization)
                  }
              }
      #endif
          }
          
          /// Add multiple dance floor attractors along the circumference of a circle with the specified placementRadius
          private func addDanceFloorAttractors(to danceFloor: Entity, count: Int, placementRadius: Float, namePrefix: String, variation: Float = 0.005) {
              let angleIncrements = 360 / count
              
              for offset in 0..<count {
                  let angle = Angle2D(degrees: Double(angleIncrements * offset))
                  let name = "\(namePrefix)-A\(offset + 1)"
                  addDanceFloorAttractor(to: danceFloor, angle: angle, placementRadius: placementRadius, name: name, variation: variation)
              }
          }
          
          /// Adds a single dance floor attractor at a point on the circumference of a circle with the specified placementRadius
          private func addDanceFloorAttractor(to danceFloor: Entity, angle: Angle2D, placementRadius: Float, name: String, variation: Float = 0.005) {
              let attractor = Entity()
              attractor.name = name
              attractor.components[AttractorComponent.self] = AttractorComponent(club: rootEntity)
              attractor.position = pointOnCircumference(angle: angle, radius: placementRadius, variation: variation)
              danceFloor.addChild(attractor)
          }
          
          /// Adds some revolving spot lights to the club
          private func addSpotlightsToTheClub() {
              let placementRadius: Float = 0.5
              let lightsWrapper = Entity()
              lightsWrapper.name = "Light Rig"
              
              let magentaLight = SpotLight()
              magentaLight.light.color = .magenta
              magentaLight.light.intensity = 500
              var lightPosition = pointOnCircumference(angle: Angle2D(degrees: 0), radius: placementRadius, y: 0.5)
              magentaLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity)
              lightsWrapper.addChild(magentaLight)
              
              let greenLight = magentaLight.clone(recursive: true)
              greenLight.light.color = .green
              lightPosition = pointOnCircumference(angle: Angle2D(degrees: 120), radius: placementRadius, y: 0.5)
              greenLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity)
              lightsWrapper.addChild(greenLight)
              
              let cyanLight = magentaLight.clone(recursive: true)
              cyanLight.light.color = .cyan
              lightPosition = pointOnCircumference(angle: Angle2D(degrees: 240), radius: placementRadius, y: 0.5)
              cyanLight.look(at: .zero, from: lightPosition, relativeTo: rootEntity)
              lightsWrapper.addChild(cyanLight)
              
              lightsWrapper.components[RevolvingComponent.self] = RevolvingComponent(speed: -0.2, relativeTo: rootEntity)
              
              rootEntity.addChild(lightsWrapper)
          }
          
          /// Repurpose some planters to make a counter and stocks with a premium aged oil, and a friendly host
          private func addCounterToTheClub() {
              guard let planter = rootEntity.findEntity(named: "planter_big"),
                    let dirt = rootEntity.findEntity(named: "dirt_big") else {
                  logger.error("Making the counter failed... too much dancing may now cause rust 🤖")
                  return
              }
              
              // Group into a container entity
              let counter = Entity()
              counter.name = "Counter"
              counter.position = [0.333, 0.05, -0.09]
              rootEntity.addChild(counter)
              
              // Repurpose existing assets
              let counterGeometry = Entity()
              counterGeometry.name = "Counter Geometry"
              counterGeometry.addChild(planter, preservingWorldTransform: true)
              counterGeometry.addChild(dirt, preservingWorldTransform: true)
              counterGeometry.scale = [2, 6, 2]
              counterGeometry.position = [-0.3335, -0.15, 0.09]
              counter.addChild(counterGeometry)
              
              var counterTopMaterial = PhysicallyBasedMaterial()
              counterTopMaterial.baseColor = .init(tint: .white)
              counterTopMaterial.roughness = .init(floatLiteral: 0)
              counterTopMaterial.metallic = .init(floatLiteral: 1)
              
              dirt.components[ModelComponent.self]?.materials = [counterTopMaterial]
              dirt.position += [0, 0.001, 0]
              
              // Add a fancy hover rail
              if let rim = rootEntity.findEntity(named: "bottom_rim_1") {
                  let hoverRailing = rim.clone(recursive: true)
                  hoverRailing.name = "Hover Railing"
                  hoverRailing.position = [0, 0.1, 0]
                  hoverRailing.scale = rim.scale(relativeTo: rootEntity) * 0.5
                  hoverRailing.components[HoverComponent.self] = HoverComponent(from: hoverRailing.position, to: hoverRailing.position + [0, -0.03, 0])
                  counter.addChild(hoverRailing)
              }
              
              // Add some bottles to the counter
              let bottles = stockBottles(placementRadius: 0.045)
              counter.addChild(bottles)
              
              // Hide any out of stock items
              for bottle in bottles.children {
                  bottle.isEnabled = bottle.components[OutOfStockComponent.self] == nil
              }
              
              // Add a friendly host
              addHostToTheCounter(counter)
          }
          
          /// Adds 9 green bottles of the finest aged oil to the counter (assuming we have them in stock)
          private func stockBottles(placementRadius: Float) -> Entity {
              let bottleRadius: Float = 0.003
              let bottleHeight: Float = 0.022
              let angleIncrement: Float = -12
              let outOfStockBrands: Set = [3]
              
              // Make a wrapper entity
              let bottleGroup = Entity()
              bottleGroup.name = "Bottle Group"
              bottleGroup.position = [0, 0.04, 0]
              bottleGroup.orientation = .init(angle: 180 * (.pi / 180), axis: [0, 1, 0])
              
              // Make a nice green material
              var bottleMaterial = PhysicallyBasedMaterial()
              bottleMaterial.baseColor = .init(tint: .green)
              bottleMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5))
              
              // A simple cylinder mesh
              let bottleMesh = MeshResource.generateCylinder(height: bottleHeight, radius: bottleRadius)
              
              // Error 1: Content occluded
              let bottle1 = Entity()
              bottle1.name = "BT1"
              bottle1.position = pointOnCircumference(angle: .zero, radius: placementRadius, y: -0.03)
              bottle1.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle1)
              
              // Error 2: Content clipped
              let bottle2 = Entity()
              bottle2.name = "BT2"
              bottle2.position = pointOnCircumference(angle: Angle2D(degrees: angleIncrement), radius: 1.6, y: bottleHeight / 2)
              bottle2.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle2)
              
              // Error 3: Content inside out
              let bottle3 = Entity()
              bottle3.name = "BT3"
              bottle3.position = pointOnCircumference(angle: Angle2D(degrees: 2 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              bottle3.scale = .init(repeating: 650)
              bottle3.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle3)
              
              // Error 4: Content not enabled
              let bottle4 = Entity()
              bottle4.name = "BT4"
              bottle4.position = pointOnCircumference(angle: Angle2D(degrees: 3 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              bottle4.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottle4.components[OutOfStockComponent.self] = OutOfStockComponent()
              bottleGroup.addChild(bottle4)
              
              // Error 5: Content not anchored
              let bottle5 = Entity()
              bottle5.name = "BT5"
              bottle5.position = pointOnCircumference(angle: Angle2D(degrees: 4 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              bottle5.components[AnchoringComponent.self] = AnchoringComponent(.plane(.horizontal, classification: .table, minimumBounds: .zero))
              bottle5.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle5)
              
              // Error 6: Content missing a mesh
              let bottle6 = Entity()
              bottle6.name = "BT6"
              bottle6.position = pointOnCircumference(angle: Angle2D(degrees: 5 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              bottle5.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle6)
              
              // Error 7: Content's material misconfigured
              let bottle7 = Entity()
              bottle7.name = "BT7"
              bottle7.position = pointOnCircumference(angle: Angle2D(degrees: 6 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              
              var simplifiedBottleMaterial = UnlitMaterial(color: .green.withAlphaComponent(0.5))
              simplifiedBottleMaterial.opacityThreshold = 1
              
              bottle7.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [simplifiedBottleMaterial])
              bottleGroup.addChild(bottle7)
              
              // Error 8: Content has a broken mesh
              let alternativeMesh = MeshResource.generateAbnormalCylinder(height: bottleHeight, radius: bottleRadius)
              let bottle8 = Entity()
              bottle8.name = "BT8"
              bottle8.position = pointOnCircumference(angle: Angle2D(degrees: 7 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              bottle8.scale = [bottle8.scale.x, bottle8.scale.y, -bottle8.scale.z]
              bottleMaterial.opacityThreshold = 0
              bottle8.components[ModelComponent.self] = ModelComponent(mesh: alternativeMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle8)
              
              // Error 9: Content not added to the scene hierarchy
              let bottle9 = Entity()
              bottle9.name = "BT9"
              bottle9.position = pointOnCircumference(angle: Angle2D(degrees: 8 * angleIncrement), radius: placementRadius, y: bottleHeight / 2)
              bottle9.components[ModelComponent.self] = ModelComponent(mesh: bottleMesh, materials: [bottleMaterial])
              bottleGroup.addChild(bottle8)
              
              // FIXME: Bottles are missing from the counter
              
              return bottleGroup
          }
          
          /// Add a host robot to the counter
          private func addHostToTheCounter(_ counter: Entity) {
              // Make a clone of our hero BOTanist
              let robotMaker = AppState()
              
              guard let skeleton = robotMaker.robotData.meshes[.body]?.findEntity(named: "rig_grp") as? ModelEntity else {
                  fatalError()
              }
              
              // But use the hover body to best complement the counter
              robotMaker.setMesh(part: .body, name: "body3")
              
              guard let head = robotMaker.robotData.meshes[.head]?.clone(recursive: true),
                    let body = robotMaker.robotData.meshes[.body]?.clone(recursive: true),
                    let backpack = robotMaker.robotData.meshes[.backpack]?.clone(recursive: true) else {
                  fatalError()
              }
              
              let robotCharacter = RobotCharacter(
                  head: head,
                  body: body,
                  backpack: backpack,
                  appState: robotMaker,
                  headOffset: skeleton.pins["head"]?.position,
                  backpackOffset: skeleton.pins["backpack"]?.position
              )
              
              // Remove the character controller and animation state, as we'll manually control these
              AnimationState.handlers.removeAll()
              robotCharacter.characterParent.components[CharacterControllerComponent.self] = nil
              
              // Take off that heavy backpack
              backpack.removeFromParent()
              
              // Setup our host using the character and add it to the counter
              let host = Entity()
              host.name = "Host"
              host.orientation = .init(angle: 300 * (.pi / 180), axis: [0, 1, 0])
              host.position = [0, 0.005, 0]
              host.components[AutomatonControl.self] = AutomatonControl(character: robotCharacter)
              host.addChild(robotCharacter.characterParent)
              counter.addChild(host)
              
              // Have them say Hi
              robotCharacter.transitionToAndPlayAnimation(.wave)
              
              // Save a reference so they can wave later when other bots enter
              self.host = host
          }
          
          /// Generates a disco ball looking entity, makes it revolve and hover, and adds it to the club
          private func addDiscoBallToTheClub() {
              // Add the top level revolving, hovering disco ball entity
              let discoBall = Entity()
              discoBall.name = "Disco Ball"
              discoBall.position = [-0.305, 0.17, 0.02]
              discoBall.components[RevolvingComponent.self] = RevolvingComponent(speed: -0.02, relativeTo: rootEntity)
              discoBall.components[HoverComponent.self] = HoverComponent(from: discoBall.position, to: discoBall.position + [0, 0.02, 0])
              
              rootEntity.addChild(discoBall)
              
              // Add a support beam to hold the disco ball
              var supportMaterial = PhysicallyBasedMaterial()
              supportMaterial.baseColor = .init(tint: .lightGray)
              supportMaterial.roughness = .init(floatLiteral: 0.8)
              supportMaterial.metallic = .init(floatLiteral: 0.8)
              
              let support = ModelEntity(mesh: .generateCylinder(height: 0.01, radius: 0.01), materials: [supportMaterial])
              support.scale = [0.2, 1.8, 0.2]
              support.position = [0, 0.05, 0]
              support.name = "Support"
              
              discoBall.addChild(support)
              
              // Add the shiny ball that is the base of our disco ball
              var backgroundMaterial = PhysicallyBasedMaterial()
              backgroundMaterial.baseColor = .init(tint: .lightGray)
              backgroundMaterial.roughness = .init(floatLiteral: 0)
              backgroundMaterial.metallic = .init(floatLiteral: 1)
              
              let background = ModelEntity(mesh: .generateSphere(radius: 0.05), materials: [backgroundMaterial])
              background.name = "Background"
              
              // FIXME: Unintentionally inheriting an ancestor's transformation
              support.addChild(background)
              
              // Add some detailed lines on top of the background
              var lineMaterial = PhysicallyBasedMaterial()
              lineMaterial.baseColor = .init(tint: .lightGray)
              lineMaterial.sheen = .init(tint: .lightGray)
              lineMaterial.emissiveColor = .init(color: .lightGray)
              lineMaterial.emissiveIntensity = 1
              lineMaterial.triangleFillMode = .lines
              
              let ballOutline = ModelEntity(mesh: .generateSphere(radius: 0.0505), materials: [lineMaterial])
              ballOutline.name = "Outline"
              
              background.addChild(ballOutline)
          }
          
          /// Marks the club as ready
          private func openDoors() {
              var management = self.doorSupervisor
              management.doorsOpen = true
              self.doorSupervisor = management
          }
          
          /// finds a point along the edge of a circle on an XZ-plane, given a radius and y value. Optionally applies some variance.
          private func pointOnCircumference(angle: Angle2D, radius: Float, variation: Float = 0, y: Float = 0) -> SIMD3<Float> {
              .init(
                  x: (Float(cos(angle)) * radius) + .random(in: -variation...variation),
                  y: y,
                  z: (Float(sin(angle)) * radius) + .random(in: -variation...variation)
              )
          }
      }
      
      // MARK: Club Management
      
      /// Manages club capacity and ready state
      struct DoorSupervisor: Component {
          let capacity: Int
          var doorsOpen = false
          var visitorCount = 0
          
          var hasCapacity: Bool {
              visitorCount < capacity
          }
      }
      
      /// Tag to indicate if a retail item is in stock
      struct OutOfStockComponent: Component {}
      
      // MARK: Revolution Control
      
      /// Works with the RevolvingSystem to apply a continuous rotation to an entity
      struct RevolvingComponent: Component {
          var speed: Float
          var angle: Float
          var axis: SIMD3<Float>
          var relativeTo: Entity?
          
          init(speed: Float = 0.05, initialAngle: Float = 0, axis: SIMD3<Float> = [0, 1, 0], relativeTo: Entity? = nil) {
              self.speed = speed
              self.angle = initialAngle
              self.axis = axis
              self.relativeTo = relativeTo
          }
      }
      
      /// Works with the RevolvingComponent to apply a continuous rotation to an entity
      @MainActor
      class RevolvingSystem: System {
          private static let query = EntityQuery(where: .has(RevolvingComponent.self))
          
          required init(scene: RealityKit.Scene) {}
          
          func update(context: SceneUpdateContext) {
              for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
                  if var revolvingComponent = entity.components[RevolvingComponent.self] {
                      let relativeTo = revolvingComponent.relativeTo
                      
                      revolvingComponent.angle += .pi * Float(context.deltaTime) * revolvingComponent.speed
                      entity.setOrientation(.init(angle: revolvingComponent.angle, axis: revolvingComponent.axis), relativeTo: relativeTo)
                      
                      entity.components[RevolvingComponent.self] = revolvingComponent
                  }
              }
          }
      }
      
      // MARK: Hover Control
      
      /// Works with the HoverSystem to apply a continuous levitation like bounce to an entity
      struct HoverComponent: Component {
          var speed: Float
          var angle: Float
          var from: SIMD3<Float>
          var to: SIMD3<Float>
          
          init(speed: Float = 0.06, angle: Float = 0, from: SIMD3<Float>, to: SIMD3<Float>) {
              self.speed = speed
              self.angle = angle
              self.from = from
              self.to = to
          }
      }
      
      /// Works with the HoverComponent to apply a continuous levitation like bounce to an entity
      @MainActor
      class HoverSystem: System {
          private static let query = EntityQuery(where: .has(HoverComponent.self))
          
          required init(scene: RealityKit.Scene) {}
          
          func update(context: SceneUpdateContext) {
              for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
                  if var hoverComponent = entity.components[HoverComponent.self] {
                      
                      hoverComponent.angle += .pi * Float(context.deltaTime) * hoverComponent.speed
                      
                      let range = hoverComponent.to - hoverComponent.from
                      let proportion = (sin(hoverComponent.angle) + 1) / 2
                      
                      entity.position = hoverComponent.from + (proportion * range)
                      
                      entity.components[HoverComponent.self] = hoverComponent
                  }
              }
          }
      }
      
      // MARK: Robot Parts
      
      /// A wrapper around a Robot Character that is actually used as an Automaton
      struct AutomatonControl: Component {
          var character: RobotCharacter
      }
      
      extension RobotCharacter {
          /// manually control the animation transition of a single robot instance
          func transitionToAndPlayAnimation(_ animationState: AnimationState) {
              if self.animationState.transition(to: animationState) {
                  playAnimation(animationState)
              }
          }
      }
      
      /// A collection of shuffled robot names for our Automatons
      @MainActor
      enum RobotNames {
          static var count: Int = 0
          static var next: String {
              count += 1
              
              return "Robo-v\(count)"
          }
      }
      
      // MARK: Teleportation
      
      /// Works with the TeleportationSystem to control spawning across all teleporters
      struct ControlCenterComponent: Component {
          typealias SpawnHandler = (Entity) -> Void
          
          var initialValue: TimeInterval
          var interval: TimeInterval
          var countdown: TimeInterval
          var rootEntity: Entity
          var _spawnHandler: SpawnHandler
          
          init(initialValue: TimeInterval, interval: TimeInterval, rootEntity: Entity, spawnHandler: @escaping SpawnHandler) {
              self.initialValue = initialValue
              self.interval = interval
              self.countdown = initialValue
              self.rootEntity = rootEntity
              self._spawnHandler = spawnHandler
          }
      }
      
      /// Represents a single Teleporter in the TeleportationSystem
      struct TeleporterComponent: Component {}
      
      /// Works with the ControlCenterComponent to control spawning across all teleporters
      @MainActor
      class TeleportationSystem: System {
          private static let controlCenterQuery = EntityQuery(where: .has(ControlCenterComponent.self))
          private static let teleporterQuery = EntityQuery(where: .has(TeleporterComponent.self))
          private static let robotQuery = EntityQuery(where: .has(AutomatonControl.self))
          
          required init(scene: RealityKit.Scene) {}
          
          func update(context: SceneUpdateContext) {
              for entity in context.entities(matching: Self.controlCenterQuery, updatingSystemWhen: .rendering) {
                  update(controlCenter: entity, context: context)
              }
          }
          
          private func safeToUse(teleporter: Entity, context: SceneUpdateContext) -> Bool {
              let someBotIsStandingToClose = context.entities(matching: Self.robotQuery, updatingSystemWhen: .rendering)
                  .contains { entity in
                      distance(entity.position(relativeTo: nil), teleporter.position(relativeTo: nil)) < 0.02
                  }
              
              return  !someBotIsStandingToClose
          }
          
          private func update(controlCenter controlCenterEntity: Entity, context: SceneUpdateContext) {
              guard var controlCenter = controlCenterEntity.components[ControlCenterComponent.self],
                    let clubManager = controlCenter.rootEntity.components[DoorSupervisor.self],
                    clubManager.hasCapacity else {
                  return
              }
              
              // 1. Decrease countdown, and activate if it reaches zero
              controlCenter.countdown -= context.deltaTime
              if controlCenter.countdown <= 0 {
                  
                  // 2. Find all the active teleporters and pick a random one
                  if let teleporter = context.entities(matching: Self.teleporterQuery, updatingSystemWhen: .rendering).shuffled().first {
                      
                      // 3. If no other robots are in the way, pass it to the designated spawn method
                      if safeToUse(teleporter: teleporter, context: context) {
                          controlCenter._spawnHandler(teleporter)
                      }
                  }
                  
                  // 4. Set the delay till the next spawn event
                  controlCenter.countdown = controlCenter.interval
              }
              
              // FIXME: Control Center is not being updated
          }
      }
      
      extension ParticleEmitterComponent.Presets {
          /// Makes a particle emitter component that looks like a teleporter
          fileprivate static var teleporter: ParticleEmitterComponent {
              var particleEmitter = ParticleEmitterComponent.Presets.rain
              
              particleEmitter.birthLocation = .surface
              particleEmitter.emitterShape = .torus
              particleEmitter.particlesInheritTransform = false
              particleEmitter.fieldSimulationSpace = .global
              particleEmitter.speed = 0.07
              particleEmitter.speedVariation = 0.03
              particleEmitter.radialAmount = 360
              particleEmitter.torusInnerRadius = 0.001
              particleEmitter.emissionDirection = [0, 1, 0]
              particleEmitter.spawnedEmitter = nil
              particleEmitter.burstCount = 5000
              particleEmitter.mainEmitter.opacityCurve = .linearFadeOut
              particleEmitter.mainEmitter.birthRate = 50
              particleEmitter.mainEmitter.birthRateVariation = 10
              particleEmitter.mainEmitter.lifeSpan = 0.5
              particleEmitter.mainEmitter.lifeSpanVariation = 0.01
              particleEmitter.mainEmitter.size = 0.001
              particleEmitter.mainEmitter.sizeVariation = 0.0005
              particleEmitter.mainEmitter.sizeMultiplierAtEndOfLifespan = 0.01
              particleEmitter.mainEmitter.stretchFactor = 10
              particleEmitter.mainEmitter.noiseStrength = 0
              particleEmitter.mainEmitter.spreadingAngle = 0
              particleEmitter.mainEmitter.angle = 0
              
              particleEmitter.spawnedEmitter = nil
              
              return particleEmitter
          }
      }
      
      // MARK: Dancing
      
      /// Represents a single Attractor in the DanceMotivationSystem
      struct AttractorComponent: Component {
          enum State {
              case vacant
              case attracting
              case motivating
          }
          
          private(set) var state: State = .vacant
          
          var target: Entity?
          var walkSpeed: Float = 0.1
          var interval: TimeInterval = 5
          var countdown: TimeInterval = 5
          var club: Entity?
          
          var isVacant: Bool {
              if case .vacant = state {
                  return true
              }
              return false
          }
          
          mutating func setTarget(_ target: Entity) {
              self.target = target
              self.state = .attracting
          }
          
          mutating func targetReached() {
              self.state = .motivating
          }
      }
      
      /// Represents a single Robot in the DanceMotivationSystem
      struct Newcomer: Component {}
      
      /// Works with the DanceMotivationSystem to provide additional Debug information to the RealityKit Debugger
      struct DanceSystemDebugComponent: Component {
          var states: UIImage? = nil
          var vacant: Int = 0
          var attracting: Int = 0
          var motivating: Int = 0
      }
      
      /// Provides additional Debug information about a single Attractor in the DanceMotivationSystem to the RealityKit Debugger
      struct AttractorDebugComponent: Component {
          var state: AttractorComponent.State
          var attractor: Entity
          var robot: Entity?
      }
      
      /// Manages the states of dance floor attractors, the movement of robots and the relationships between them
      @MainActor
      class DanceMotivationSystem: System {
          private static let attractorQuery = EntityQuery(where: .has(AttractorComponent.self))
          private static let targetQuery = EntityQuery(where: .has(Newcomer.self))
          private static let clubbersQuery = EntityQuery(where: .has(AutomatonControl.self))
          private static let debugRootQuery = EntityQuery(where: .has(DanceSystemDebugComponent.self))
          private static let debugVisualizationsQuery = EntityQuery(where: .has(AttractorDebugComponent.self))
          
          required init(scene: RealityKit.Scene) {}
          
          func update(context: SceneUpdateContext) {
              
              // 1. Check for newcomers at the club who could be enticed to come and dance
              for visitor in context.entities(matching: Self.targetQuery, updatingSystemWhen: .rendering) {
                  
                  // 2. Randomly pick an attractor
                  guard let attractor = context.entities(matching: Self.attractorQuery, updatingSystemWhen: .rendering)
                      .filter({ $0.components[AttractorComponent.self]?.isVacant ?? false })
                      .randomElement() else {
                      return
                  }
                  
                  // 3. Start attracting the visitor
                  var attractorComponent = attractor.components[AttractorComponent.self]!
                  attractorComponent.setTarget(visitor)
                  attractor.components[AttractorComponent.self] = attractorComponent
                  
                  // FIXME: Stop attractors competing over the same bot
              }
              
              // Let the attractors do their thing and attract visitors to come and dance
              for attractor in context.entities(matching: Self.attractorQuery, updatingSystemWhen: .rendering) {
                  guard var attractorComponent = attractor.components[AttractorComponent.self] else {
                      continue
                  }
                  
                  switch attractorComponent.state {
                  case .attracting:
                      if let updatedAttractorComponent = attractRobot(attractor: attractor, deltaTime: Float(context.deltaTime)) {
                          attractorComponent = updatedAttractorComponent
                      }
                      
                  case .motivating:
                      if let updatedAttractorComponent = motivateRobot(attractor: attractor, context: context) {
                          attractorComponent = updatedAttractorComponent
                      }
                      
                  default:
                      break
                  }
                  
                  // save changes
                  attractor.components[AttractorComponent.self] = attractorComponent
              }
              
      #if DEBUG
              updateDebugInfo(context: context)
      #endif
          }
          
          private func attractRobot(attractor: Entity, deltaTime: Float) -> AttractorComponent? {
              guard var attractorComponent = attractor.components[AttractorComponent.self],
                    case .attracting = attractorComponent.state,
                    let target = attractorComponent.target,
                    let robotCharacter = target.components[AutomatonControl.self]?.character else {
                  return nil
              }
              
              // robots wave when they first arrive, make sure that is completed first before moving
              var transitionAnimationTo: AnimationState?
              switch robotCharacter.animationState {
              case .wave: transitionAnimationTo = .idle
              case .idle: transitionAnimationTo = .walkLoop
              case .walkLoop: transitionAnimationTo = nil
              default: return attractorComponent
              }
              
              if let transitionAnimationTo {
                  if robotCharacter.animationState.transition(to: transitionAnimationTo) {
                      robotCharacter.playAnimation(robotCharacter.animationState)
                  }
              }
              
              // Convert the robot and target positions into the same coordinate system
              let targetPosition = target.position(relativeTo: attractorComponent.club)
              var danceSpotPosition = attractor.position(relativeTo: attractorComponent.club)
              danceSpotPosition.y = targetPosition.y
              
              let movementVector = danceSpotPosition - targetPosition
              let normalizedMovement = movementVector / length(movementVector)
              let move = normalizedMovement * deltaTime * attractorComponent.walkSpeed
              
              target.setPosition(targetPosition + move, relativeTo: attractorComponent.club)
              
              robotCharacter.characterModel.look(at: robotCharacter.characterModel.position - normalizedMovement,
                                                 from: robotCharacter.characterModel.position, relativeTo: robotCharacter.characterParent)
              
              // If the target is more or less in position then attach to the dance spot and change state to motivating
              if distance(danceSpotPosition, target.position(relativeTo: attractorComponent.club)) < 0.005 {
                  attractor.addChild(target, preservingWorldTransform: true)
                  
                  // Start Dancing
                  robotCharacter.transitionToAndPlayAnimation(.celebrate)
                  
                  // Update attractor state
                  attractorComponent.targetReached()
              }
              
              return attractorComponent
          }
          
          private func motivateRobot(attractor: Entity, context: SceneUpdateContext) -> AttractorComponent? {
              guard var attractorComponent = attractor.components[AttractorComponent.self],
                    case .motivating = attractorComponent.state,
                    let target = attractorComponent.target,
                    let robotCharacter = target.components[AutomatonControl.self]?.character else {
                  return nil
              }
              
              attractorComponent.countdown -= context.deltaTime
              
              if attractorComponent.countdown <= 0 {
                  // Turn to face a random fellow clubber
                  if let friend = Array(context.entities(matching: Self.clubbersQuery, updatingSystemWhen: .rendering)).randomElement() {
                      let friendsPosition = friend.position(relativeTo: robotCharacter.characterParent)
                      
                      robotCharacter.characterModel.look(at: friendsPosition,
                                                         from: robotCharacter.characterModel.position, relativeTo: robotCharacter.characterParent)
                      
                      // TODO: remove me
                      print("🔥 friendsPosition \(friendsPosition) targetPosition \(robotCharacter.characterModel.position)")
                  }
                  
                  attractorComponent.countdown = attractorComponent.interval
              }
              
              return attractorComponent
          }
          
      #if DEBUG
          let vacantColor = UnlitMaterial.BaseColor(tint: .yellow.withAlphaComponent(0.5))
          let attractingColor = UnlitMaterial.BaseColor(tint: .orange.withAlphaComponent(0.5))
          let motivatingColor = UnlitMaterial.BaseColor(tint: .red.withAlphaComponent(0.5))
          
          private func updateDebugInfo(context: SceneUpdateContext) {
              var vacantCount: Int = 0
              var attractingCount: Int = 0
              var motivatingCount: Int = 0
              
              context.entities(matching: Self.debugVisualizationsQuery, updatingSystemWhen: .rendering).forEach { visualization in
                  guard let visualizationComponent = visualization.components[AttractorDebugComponent.self],
                        let attractorComponent = visualizationComponent.attractor.components[AttractorComponent.self] else {
                      return
                  }
                  
                  updateVisualizationEntity(visualization, relativeTo: attractorComponent.club)
                  
                  switch attractorComponent.state {
                  case .vacant: vacantCount += 1
                  case .attracting: attractingCount += 1
                  case .motivating: motivatingCount += 1
                  }
              }
              
              context.entities(matching: Self.debugRootQuery, updatingSystemWhen: .rendering).forEach { debugRoot in
                  if var debugComponent = debugRoot.components[DanceSystemDebugComponent.self] {
                      debugComponent.vacant = vacantCount
                      debugComponent.attracting = attractingCount
                      debugComponent.motivating = motivatingCount
                      debugComponent.states = makeChart(vacantCount: vacantCount, attractingCount: attractingCount, motivatingCount: motivatingCount)
                      debugRoot.components[DanceSystemDebugComponent.self] = debugComponent
                  }
              }
          }
          
          private func updateVisualizationEntity(_ visualization: Entity, relativeTo root: Entity?) {
              guard var visualizationComponent = visualization.components[AttractorDebugComponent.self],
                    let attractorComponent = visualizationComponent.attractor.components[AttractorComponent.self] else {
                  return
              }
              
              // Update the position
              var position = visualizationComponent.attractor.position(relativeTo: root)
              position.y = visualization.position.y
              visualization.setPosition(position, relativeTo: root)
              
              // Update the state
              visualizationComponent.state = attractorComponent.state
              visualization.name = "[Debug] \(visualizationComponent.attractor.name) (\(attractorComponent.state))"
              
              // Update the base material color to signify the attractor state
              if var modelComponent = visualization.components[ModelComponent.self],
                 var material = modelComponent.materials.first as? UnlitMaterial {
                  
                  switch attractorComponent.state {
                  case .vacant: material.color = vacantColor
                  case .attracting: material.color = attractingColor
                  case .motivating: material.color = motivatingColor
                  }
                  
                  modelComponent.materials = [material]
                  visualization.components[ModelComponent.self] = modelComponent
              }
              
              // Update the target
              visualizationComponent.robot = attractorComponent.target
              visualization.components[AttractorDebugComponent.self] = visualizationComponent
          }
          
          private func makeChart(vacantCount: Int, attractingCount: Int, motivatingCount: Int) -> UIImage? {
              ImageRenderer(content: chartView(vacantCount: vacantCount, attractingCount: attractingCount, motivatingCount: motivatingCount)).uiImage
          }
          
          private func chartView(vacantCount: Int, attractingCount: Int, motivatingCount: Int) -> some View {
              Chart(
                  [
                      (name: "Vacant", count: vacantCount),
                      (name: "Attracting", count: attractingCount),
                      (name: "Motivating", count: motivatingCount)
                  ], id: \.name) { name, count in
                      SectorMark(
                          angle: .value("Value", count),
                          angularInset: 1.5
                      )
                      .cornerRadius(5)
                      .foregroundStyle(by: .value("Name", name))
              }
              .chartLegend(.hidden)
              .chartForegroundStyleScale(["Vacant": .yellow, "Attracting": .orange, "Motivating": .red])
              .frame(width: 1024, height: 1024)
          }
          
      #endif
      }
      
      // MARK: Debug Helpers
      
      extension Entity {
          /// creates an semi-transparent entity that can be useful in debug invisible entities in the RealityKit Debugger
          static func makeDebugMarker(name: String? = nil, height: Float, radius: Float, color: UIColor = .white, enabled: Bool = false) -> Entity? {
      #if DEBUG
              var debugMaterial = UnlitMaterial()
              debugMaterial.color = .init(tint: color)
              debugMaterial.blending = .transparent(opacity: 0.7)
              
              let marker = ModelEntity(mesh: .generateCylinder(height: height, radius: radius), materials: [debugMaterial])
              if let name {
                  marker.name = name
              }
              marker.isEnabled = enabled
              
              return marker
      #else
              return nil
      #endif
          }
          
          /// adds an semi-transparent child entity that can be useful in debug invisible entities in the RealityKit Debugger
          @discardableResult
          func addDebugMarker(name: String? = nil, height: Float? = nil, radius: Float? = nil, color: UIColor = .white, enabled: Bool = false) -> Entity? {
      #if DEBUG
              var markerRadius: Float
              if radius != nil {
                  markerRadius = radius!
              } else {
                  // If no provided radius then calculate from the visual bounds
                  let extents = visualBounds(relativeTo: nil).extents
                  let boundingXZRadius = max(extents.x, extents.z) / 2
                  
                  if boundingXZRadius.isNormal {
                      markerRadius = boundingXZRadius
                  } else {
                      // If no visual bounds then use a default radius of 1cm
                      markerRadius = 0.01 * scale(relativeTo: nil).max()
                  }
              }
              
              // If no provided height then use a default value of 10cm
              let markerHeight = height ?? 0.1 * scale(relativeTo: nil).max()
              
              let name = name ?? "[Debug] \(self.name)"
              if let marker = Entity.makeDebugMarker(name: name, height: markerHeight, radius: markerRadius, color: color, enabled: enabled) {
                  marker.position = [0, markerHeight / 2, 0]
                  addChild(marker)
                  
                  return marker
              }
      #endif
              return nil
          }
      }
      
      // MARK: Demo Helpers
      
      extension MeshResource {
          /// Generates an cylinder with all the normals facing downwards. Probably has no uses other than demo'ing a broken mesh.
          static func generateAbnormalCylinder(height: Float, radius: Float) -> MeshResource {
              let meshResource = MeshResource.generateCylinder(height: height, radius: radius)
              var contents = meshResource.contents
              let models = contents.models.map { model in
                  var model = model
                  let parts = model.parts.map { part in
                      var part = part
                      part.normals = part.normals.map { normals in
                          let transformedNormals: [SIMD3<Float>] = normals.map { _ in
                              [0, -1, 0]
                          }
                          
                          return MeshBuffer(transformedNormals)
                      }
                      
                      return part
                  }
                  model.parts = MeshPartCollection(parts)
                  
                  return model
              }
              contents.models = MeshModelCollection(models)
              try? meshResource.replace(with: contents)
              
              return meshResource
          }
      }
    • 3:02 - Add a volumetric club scene

      WindowGroup(id: "RobotClub") {
          GeometryReader3D { geometry in
              ClubView()
                  .volumeBaseplateVisibility(.visible)
                  .environment(appState)
                  .scaleEffect(geometry.size.width / initialVolumeSize.width)
          }
          .onAppear {
              dismissWindow(id: "RobotCreation")
          }
      }
      .windowStyle(.volumetric)
      .defaultWorldScaling(.dynamic)
      .defaultSize(initialVolumeSize)
    • 3:09 - Add a button to open the club

      VStack {
          Button("🪩") {
              openWindow(id: "RobotClub")
          }
          .padding()
      
          Spacer()
      }
      .padding([.trailing, .top])
    • 6:50 - FIX: Unintentionally inheriting an ancestor's transformation

      discoBall.addChild(background)
    • 10:18 - FIX: Control Center is not being updated

      // 5. Save updated component back to the entity
      controlCenterEntity.components[ControlCenterComponent.self] = controlCenter
    • 18:15 - FIX: Stocking bottles

      private func stockBottles(placementRadius: Float) -> Entity {
          let bottleRadius: Float = 0.003
          let bottleHeight: Float = 0.022
          let angleIncrement: Float = -12
          let outOfStockBrands: Set = [3]
          
          // Make a wrapper entity
          let bottleGroup = Entity()
          bottleGroup.name = "Bottle Group"
          bottleGroup.position = [0, 0.04, 0]
          bottleGroup.orientation = .init(angle: 180 * (.pi / 180), axis: [0, 1, 0])
          
          // Make a nice green material
          var bottleMaterial = PhysicallyBasedMaterial()
          bottleMaterial.baseColor = .init(tint: .green)
          bottleMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.5))
          
          for i in 0..<9 {
              let angle = Angle2D(degrees: angleIncrement * Float(i))
              let bottleMesh = MeshResource.generateCylinder(height: bottleHeight, radius: bottleRadius)
              let bottle = ModelEntity(mesh: bottleMesh, materials: [bottleMaterial])
              bottle.name = "BT\(i)"
              bottle.position = pointOnCircumference(angle: angle, radius: placementRadius, y: bottleHeight / 2)
              if outOfStockBrands.contains(i) {
                  bottle.components[OutOfStockComponent.self] = OutOfStockComponent()
              }
              
              bottleGroup.addChild(bottle)
          }
          
          return bottleGroup
      }
    • 22:48 - FIX: Attractors

      // 4. Untag them as a Newcomer
      visitor.components[Newcomer.self] = nil
  • 찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.

    쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.

Developer Footer

  • 비디오
  • WWDC24
  • RealityKit 디버거 자세히 알아보기
  • 메뉴 열기 메뉴 닫기
    • iOS
    • iPadOS
    • macOS
    • tvOS
    • visionOS
    • watchOS
    메뉴 열기 메뉴 닫기
    • Swift
    • SwiftUI
    • Swift Playground
    • TestFlight
    • Xcode
    • Xcode Cloud
    • SF Symbols
    메뉴 열기 메뉴 닫기
    • 손쉬운 사용
    • 액세서리
    • 앱 확장 프로그램
    • App Store
    • 오디오 및 비디오(영문)
    • 증강 현실
    • 디자인
    • 배포
    • 교육
    • 서체(영문)
    • 게임
    • 건강 및 피트니스
    • 앱 내 구입
    • 현지화
    • 지도 및 위치
    • 머신 러닝
    • 오픈 소스(영문)
    • 보안
    • Safari 및 웹(영문)
    메뉴 열기 메뉴 닫기
    • 문서(영문)
    • 튜토리얼
    • 다운로드(영문)
    • 포럼(영문)
    • 비디오
    메뉴 열기 메뉴 닫기
    • 지원 문서
    • 문의하기
    • 버그 보고
    • 시스템 상태(영문)
    메뉴 열기 메뉴 닫기
    • Apple Developer
    • App Store Connect
    • 인증서, 식별자 및 프로파일(영문)
    • 피드백 지원
    메뉴 열기 메뉴 닫기
    • Apple Developer Program
    • Apple Developer Enterprise Program
    • App Store Small Business Program
    • MFi Program(영문)
    • News Partner Program(영문)
    • Video Partner Program(영문)
    • Security Bounty Program(영문)
    • Security Research Device Program(영문)
    메뉴 열기 메뉴 닫기
    • Apple과의 만남
    • Apple Developer Center
    • App Store 어워드(영문)
    • Apple 디자인 어워드
    • Apple Developer Academy(영문)
    • WWDC
    Apple Developer 앱 받기
    Copyright © 2025 Apple Inc. 모든 권리 보유.
    약관 개인정보 처리방침 계약 및 지침