스트리밍은 대부분의 브라우저와
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
리소스
관련 비디오
WWDC24
-
다운로드
안녕하세요, 저는 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
-
-
찾고 계신 콘텐츠가 있나요? 위에 주제를 입력하고 원하는 내용을 바로 검색해 보세요.
쿼리를 제출하는 중에 오류가 발생했습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.