자습서: Azure Spatial Anchors를 사용하여 새 HoloLens Unity 앱을 만드는 단계별 지침
이 자습서에서는 Azure Spatial Anchors를 사용하여 새 HoloLens Unity 앱을 만드는 방법을 보여줍니다.
필수 조건
이 자습서를 완료하려면 다음이 설치되어 있어야 합니다.
- PC - Windows를 실행하는 PC
- 유니버설 Windows 플랫폼 개발 워크로드 및 Windows 10 SDK(10.0.18362.0 이상) 구성 요소로 설치된 Visual Studio - Visual Studio 2019 Visual Studio용 C++/WinRT Visual Studio 확장(VSIX)은 Visual Studio Marketplace에서 설치합니다.
- HoloLens - 개발자 모드가 사용 설정된 HoloLens 디바이스입니다. 이 문서에는 Windows 10 2020년 5월 업데이트가 있는 HoloLens 디바이스가 필요합니다. HoloLens의 최신 릴리스로 업데이트하려면 설정 앱을 열고 업데이트 및 보안으로 이동한 다음, 업데이트 확인 단추를 선택합니다.
- Unity - Unity 2020.3.25(모듈 유니버설 Windows 플랫폼 빌드 지원 및 Windows 빌드 지원(IL2CPP) 포함)
Unity 프로젝트 만들기 및 설정
새 프로젝트 만들기
- Unity 허브에서 새 프로젝트를 선택합니다.
- 3D 선택
- 프로젝트 이름을 입력하고 저장 위치를 입력합니다.
- 프로젝트 만들기를 선택하고 Unity가 프로젝트를 만들 때까지 기다립니다.
빌드 플랫폼 변경
- 통합 편집기에서 파일>빌드 설정을 선택합니다.
- 유니버설 Windows 플랫폼을 선택한 다음 플랫폼 전환을 선택합니다. Unity가 모든 파일 처리를 완료할 때까지 기다리세요.
ASA 및 OpenXR 가져오기
- Mixed Reality Feature Tool 실행
- 프로젝트 경로(Assets, Packages, ProjectSettings 등과 같은 폴더가 포함된 폴더)를 선택하고 기능 검색을 선택합니다.
- Azure Mixed Reality 서비스에서 둘 다 선택합니다.
- Azure Spatial Anchors SDK 코어
- Windows용 Azure Spatial Anchors SDK
- 플랫폼 지원에서 다음을 선택합니다.
- Mixed Reality OpenXR 플러그 인
참고 항목
카탈로그를 새로 고쳤고 각각에 대해 최신 버전이 선택되었는지 확인합니다.
- 기능 가져오기 -->가져오기 -->승인 -->종료를 누릅니다.
- Unity 창의 포커스를 다시 맞추면 Unity가 모듈 가져오기를 시작합니다.
- 새 입력 시스템 사용에 대한 메시지가 표시되면 예를 선택하여 Unity를 다시 시작하고 백 엔드를 사용하도록 설정합니다.
프로젝트 설정 지정
이제 개발에 Windows Holographic SDK를 사용할 수 있도록 하는 일부 Unity 프로젝트 설정을 지정합니다.
OpenXR 설정 변경
- 파일>빌드 설정을 선택합니다(이전 단계에서 여전히 열려 있을 수 있음).
- 플레이어 설정...을 선택합니다.
- XR 플러그 인 관리를 선택합니다.
- 유니버설 Windows 플랫폼 설정 탭이 선택되었는지 확인하고 OpenXR 옆과 Microsoft HoloLens 기능 그룹 옆의 확인란을 선택합니다.
- 모든 OpenXR 문제를 표시하려면 OpenXR 옆에 있는 노란색 경고 기호를 선택합니다.
- 모두 수정을 선택합니다.
- "적어도 하나의 상호 작용 프로필을 추가해야 합니다" 문제를 해결하려면 편집을 선택하여 OpenXR 프로젝트 설정을 엽니다. 그런 다음 상호 작용 프로필에서 + 기호를 선택하고 Microsoft Hand 상호 작용 프로필을 선택합니다.
품질 설정 변경
- 편집>프로젝트 설정>품질을 선택합니다.
- 유니버설 Windows 플랫폼 로고 아래 열에서 기본 행의 화살표를 선택하고 매우 낮음을 선택합니다. 유니버설 Windows 플랫폼 열 및 매우 낮음 행의 상자가 녹색이면 설정이 올바르게 적용된 것입니다.
기능 설정
- 편집>프로젝트 설정>플레이어로 이동합니다(이전 단계에서 열어둔 상태일 수 있음).
- 유니버설 Windows 플랫폼 설정 탭이 선택되었는지 확인합니다.
- 게시 설정 구성 섹션에서 다음을 사용하도록 설정합니다.
- InternetClient
- InternetClientServer
- PrivateNetworkClientServer
- SpatialPerception(이미 사용하도록 설정되었을 수 있음)
기본 카메라 설정
- 계층 구조 패널에서 주 카메라를 선택합니다.
- 검사기에서 해당 변환 위치를 0,0,0으로 설정합니다.
- 플래그 지우기 속성에서 드롭다운을 Skybox에서 단색으로 변경합니다.
- 백그라운드 필드를 선택하여 색 편집기를 엽니다.
- R, G, B 및 A를 0으로 설정합니다.
- 하단에서 구성 요소 추가를 선택하고 추적 포즈 드라이버 구성 요소를 카메라에 추가합니다.
사용해 보기 #1
이제 HoloLens 디바이스에 배포할 준비가 된 빈 장면이 있어야 합니다. 모든 항목이 작동하는지 테스트하려면 Unity에서 앱을 빌드하고 Visual Studio에서 배포합니다. 이렇게 하려면 Visual Studio를 사용하여 배포 및 디버그를 따릅니다. Unity 시작 화면이 표시된 후 명확한 화면이 표시됩니다.
Spatial Anchors 리소스 만들기
Azure Portal로 이동합니다.
왼쪽 창에서 리소스 만들기를 선택합니다.
검색 상자를 사용하여 Spatial Anchors를 검색합니다.
Spatial Anchors를 선택한 다음, 만들기를 선택합니다.
Spatial Anchors 계정 창에서 다음을 수행합니다.
일반 영숫자 문자를 사용하여 고유한 리소스 이름을 입력합니다.
리소스를 연결할 구독을 선택합니다.
새로 만들기를 선택하여 리소스 그룹을 만듭니다. 이름을 myResourceGroup으로 지정한 다음, 확인을 선택합니다.
리소스 그룹은 웹앱, 데이터베이스, 스토리지 계정과 같은 Azure 리소스가 배포되고 관리되는 논리적 컨테이너입니다. 예를 들어 나중에 간단한 단계 하나만으로 전체 리소스 그룹을 삭제하도록 선택할 수 있습니다.
리소스를 배치할 위치(Azure 지역)를 선택합니다.
리소스 만들기를 시작하려면 만들기를 선택합니다.
리소스를 만든 후 Azure Portal은 배포가 완료되었음을 표시합니다.
리소스로 이동을 선택합니다. 이제 리소스 속성을 볼 수 있습니다.
나중에 사용하기 위해 리소스의 계정 ID 값을 텍스트 편집기에 복사합니다.
또한 리소스의 계정 도메인 값을 텍스트 편집기에 복사합니다.
설정 아래에서 액세스 키를 선택합니다. 나중에 사용하기 위해 기본 키 값, 계정 키를 텍스트 편집기에 복사합니다.
만들기 및 스크립트 추가
- Unity의 Project 창에서 Assets 폴더에 Scripts라는 새 폴더를 만듭니다.
- 폴더에서 마우스 오른쪽 단추로 클릭->만들기 ->C# 스크립트. 제목을 AzureSpatialAnchorsScript로 지정
- GameObject ->빈 항목 만들기로 이동합니다.
- 이를 선택하고 검사기에서 이름을 GameObject에서 AzureSpatialAnchors로 바꿉니다.
- 아직
GameObject
에 있음- 위치를 0,0,0으로 설정
- 구성 요소 추가를 선택하고 AzureSpatialAnchorsScript를 검색한 후 추가
- 구성 요소 추가를 다시 선택하고 AR 앵커 관리자를 검색하여 추가합니다. 이렇게 하면 AR 세션 원본도 자동으로 추가됩니다.
- 구성 요소 추가를 다시 선택하고 SpatialAnchorManager 스크립트를 검색하여 추가합니다.
- 이전 단계에서 Azure Portal의 공간 앵커 리소스로부터 복사한 계정 ID, 계정 키 및 계정 도메인을 추가된 SpatialAnchorManager 구성 요소에 채웁니다.
앱 개요
Microsoft 앱은 다음과 같은 상호 작용을 지원합니다.
제스처 | 작업 |
---|---|
아무 곳이나 탭 | 세션 시작/계속 + 손 위치에서 앵커 만들기 |
앵커에 탭 | GameObject 삭제 + ASA 클라우드 서비스에서 앵커 삭제 |
탭 + 2초 동안 누르기(+ 세션 실행 중) | 세션을 중지하고 모든 GameObjects 를 제거합니다. ASA Cloud Service에서 앵커 유지 |
탭 + 2초 동안 누르기(+ 세션이 실행되지 않음) | 세션을 시작하고 모든 앵커를 찾습니다. |
탭 추가 인식
사용자의 탭 제스처을 인식할 수 있도록 스크립트에 일부 코드를 추가해 보겠습니다.
- Unity 프로젝트 창에서 스크립트를 두 번 클릭하여 Visual Studio에서
AzureSpatialAnchorsScript.cs
를 엽니다. - 클래스에 다음 배열을 추가합니다.
public class AzureSpatialAnchorsScript : MonoBehaviour
{
/// <summary>
/// Used to distinguish short taps and long taps
/// </summary>
private float[] _tappingTimer = { 0, 0 };
- Update() 메서드 아래에 다음 두 메서드를 추가합니다. 추후 구현을 추가할 예정입니다.
// Update is called once per frame
void Update()
{
}
/// <summary>
/// Called when a user is air tapping for a short time
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
}
/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
}
- 다음 가져오기 추가
using UnityEngine.XR;
Update()
메서드 상단에 다음 코드를 추가합니다. 이렇게 하면 앱이 짧고 긴(2초) 손 탭 제스처를 인식할 수 있습니다.
// Update is called once per frame
void Update()
{
//Check for any air taps from either hand
for (int i = 0; i < 2; i++)
{
InputDevice device = InputDevices.GetDeviceAtXRNode((i == 0) ? XRNode.RightHand : XRNode.LeftHand);
if (device.TryGetFeatureValue(CommonUsages.primaryButton, out bool isTapping))
{
if (!isTapping)
{
//Stopped Tapping or wasn't tapping
if (0f < _tappingTimer[i] && _tappingTimer[i] < 1f)
{
//User has been tapping for less than 1 sec. Get hand position and call ShortTap
if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
{
ShortTap(handPosition);
}
}
_tappingTimer[i] = 0;
}
else
{
_tappingTimer[i] += Time.deltaTime;
if (_tappingTimer[i] >= 2f)
{
//User has been air tapping for at least 2sec. Get hand position and call LongTap
if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
{
LongTap();
}
_tappingTimer[i] = -float.MaxValue; // reset the timer, to avoid retriggering if user is still holding tap
}
}
}
}
}
SpatialAnchorManager 구성 및 추가
ASA SDK는 ASA 서비스를 호출하기 위해 SpatialAnchorManager
라는 간단한 인터페이스를 제공합니다. AzureSpatialAnchorsScript.cs
에 변수로 추가해 보겠습니다.
먼저 가져오기를 추가합니다.
using Microsoft.Azure.SpatialAnchors.Unity;
그런 다음 변수를 선언합니다.
public class AzureSpatialAnchorsScript : MonoBehaviour
{
/// <summary>
/// Used to distinguish short taps and long taps
/// </summary>
private float[] _tappingTimer = { 0, 0 };
/// <summary>
/// Main interface to anything Spatial Anchors related
/// </summary>
private SpatialAnchorManager _spatialAnchorManager = null;
Start()
메서드에서 이전 단계에서 추가한 구성 요소에 변수를 할당합니다.
// Start is called before the first frame update
void Start()
{
_spatialAnchorManager = GetComponent<SpatialAnchorManager>();
}
디버그 및 오류 로그를 받으려면 다른 콜백을 구독해야 합니다.
// Start is called before the first frame update
void Start()
{
_spatialAnchorManager = GetComponent<SpatialAnchorManager>();
_spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - Debug: {args.Message}");
_spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - Error: {args.ErrorMessage}");
}
참고 항목
로그를 보려면 Unity에서 프로젝트를 빌드하고 Visual Studio 솔루션 .sln
을 연 후 디버그 --> 디버깅으로 실행을 선택하고 앱이 실행되는 동안 HoloLens를 컴퓨터에 연결된 상태로 둡니다.
세션 시작
앵커를 만들고 찾으려면 먼저 세션을 시작해야 합니다. StartSessionAsync()
를 호출할 때 SpatialAnchorManager
는 필요한 경우 세션을 만든 다음 시작합니다. 이를 ShortTap()
메서드에 추가해 보겠습니다.
/// <summary>
/// Called when a user is air tapping for a short time
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
await _spatialAnchorManager.StartSessionAsync();
}
앵커 만들기
이제 세션이 실행 중이므로 앵커를 만들 수 있습니다. 이 애플리케이션에서는 만들어진 앵커 GameObjects
및 만들어진 앵커 식별자(앵커 ID)를 추적하려고 합니다. 코드에 두 개의 목록을 추가해 보겠습니다.
using Microsoft.Azure.SpatialAnchors.Unity;
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR;
/// <summary>
/// Main interface to anything Spatial Anchors related
/// </summary>
private SpatialAnchorManager _spatialAnchorManager = null;
/// <summary>
/// Used to keep track of all GameObjects that represent a found or created anchor
/// </summary>
private List<GameObject> _foundOrCreatedAnchorGameObjects = new List<GameObject>();
/// <summary>
/// Used to keep track of all the created Anchor IDs
/// </summary>
private List<String> _createdAnchorIDs = new List<String>();
매개 변수로 정의된 위치에 앵커를 만드는 CreateAnchor
메서드를 만들어 보겠습니다.
using System.Threading.Tasks;
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
}
공간 앵커에는 위치뿐만 아니라 회전도 있으므로 만들 때 항상 HoloLens를 향하도록 회전을 설정해 보겠습니다.
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
{
headPosition = Vector3.zero;
}
Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);
}
이제 원하는 앵커의 위치와 회전이 있으므로 가시적인 GameObject
를 만들어 보겠습니다. Spatial Anchors의 주요 목적은 공통적이고 지속적인 참조 프레임을 제공하는 것이기 때문에 Spatial Anchors는 앵커 GameObject
가 최종 사용자에게 표시될 필요가 없습니다. 이 자습서의 목적을 위해 앵커를 큐브로 시각화합니다. 각 앵커는 흰색 큐브로 초기화되며 만들기 프로세스가 성공하면 녹색 큐브로 바뀝니다.
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
{
headPosition = Vector3.zero;
}
Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.transform.position = position;
anchorGameObject.transform.rotation = orientationTowardsHead;
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
}
참고 항목
기본 Unity 빌드에 포함된 레거시 셰이더를 사용하고 있습니다. 기본 셰이더와 같은 다른 셰이더는 수동으로 지정되거나 직접 장면의 일부인 경우에만 포함됩니다. 셰이더가 포함되지 않은 상태에서 애플리케이션이 셰이더를 렌더링하려고 하면 분홍색 재질이 됩니다.
이제 Spatial Anchor 구성 요소를 추가하고 구성해 보겠습니다. 앵커 만료일을 앵커를 만든 날짜로부터 3일 후로 설정하고 있습니다. 그 후에는 클라우드에서 자동으로 삭제됩니다. 가져오기를 추가해야 합니다.
using Microsoft.Azure.SpatialAnchors;
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
{
headPosition = Vector3.zero;
}
Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.transform.position = position;
anchorGameObject.transform.rotation = orientationTowardsHead;
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
//Add and configure ASA components
CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
await cloudNativeAnchor.NativeToCloud();
CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);
}
앵커를 저장하려면 사용자가 환경 데이터를 수집해야 합니다.
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
{
headPosition = Vector3.zero;
}
Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.transform.position = position;
anchorGameObject.transform.rotation = orientationTowardsHead;
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
//Add and configure ASA components
CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
await cloudNativeAnchor.NativeToCloud();
CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);
//Collect Environment Data
while (!_spatialAnchorManager.IsReadyForCreate)
{
float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
Debug.Log($"ASA - Move your device to capture more environment data: {createProgress:0%}");
}
}
참고 항목
HoloLens는 앵커 주변에서 이미 캡처된 환경 데이터를 재사용할 수 있으므로 IsReadyForCreate
가 처음 호출될 때 이미 true가 됩니다.
이제 클라우드 공간 앵커가 준비되었으므로 여기에서 실제 저장을 시도할 수 있습니다.
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
{
headPosition = Vector3.zero;
}
Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.transform.position = position;
anchorGameObject.transform.rotation = orientationTowardsHead;
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
//Add and configure ASA components
CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
await cloudNativeAnchor.NativeToCloud();
CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);
//Collect Environment Data
while (!_spatialAnchorManager.IsReadyForCreate)
{
float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
Debug.Log($"ASA - Move your device to capture more environment data: {createProgress:0%}");
}
Debug.Log($"ASA - Saving cloud anchor... ");
try
{
// Now that the cloud spatial anchor has been prepared, we can try the actual save here.
await _spatialAnchorManager.CreateAnchorAsync(cloudSpatialAnchor);
bool saveSucceeded = cloudSpatialAnchor != null;
if (!saveSucceeded)
{
Debug.LogError("ASA - Failed to save, but no exception was thrown.");
return;
}
Debug.Log($"ASA - Saved cloud anchor with ID: {cloudSpatialAnchor.Identifier}");
_foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
_createdAnchorIDs.Add(cloudSpatialAnchor.Identifier);
anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.green;
}
catch (Exception exception)
{
Debug.Log("ASA - Failed to save anchor: " + exception.ToString());
Debug.LogException(exception);
}
}
마지막으로 ShortTap
메서드에 함수 호출을 추가해 보겠습니다.
/// <summary>
/// Called when a user is air tapping for a short time
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
await _spatialAnchorManager.StartSessionAsync();
await CreateAnchor(handPosition);
}
이제 앱에서 여러 앵커를 만들 수 있습니다. 이제 모든 디바이스는 앵커 ID를 알고 있고 Azure의 동일한 Spatial Anchors 리소스에 액세스할 수 있는 한 만들어진 앵커를 찾을 수 있습니다(아직 만료되지 않은 경우).
세션 및 중지 GameObjects 제거
모든 앵커를 찾는 두 번째 디바이스를 에뮬레이션하기 위해 이제 세션을 중지하고 모든 앵커 GameObject를 제거합니다(앵커 ID는 유지함). 그런 다음 새 세션을 시작하고 저장된 앵커 ID를 사용하여 앵커를 쿼리합니다.
SpatialAnchorManager
는 단순히 DestroySession()
메서드를 호출하여 세션 중지를 처리할 수 있습니다. 이를 LongTap()
메서드에 추가해 보겠습니다.
/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
_spatialAnchorManager.DestroySession();
}
모든 앵커 GameObjects
를 제거하는 방법을 만들어 보겠습니다.
/// <summary>
/// Destroys all Anchor GameObjects
/// </summary>
private void RemoveAllAnchorGameObjects()
{
foreach (var anchorGameObject in _foundOrCreatedAnchorGameObjects)
{
Destroy(anchorGameObject);
}
_foundOrCreatedAnchorGameObjects.Clear();
}
그리고 LongTap()
에서 세션을 제거한 후 호출합니다.
/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
// Stop Session and remove all GameObjects. This does not delete the Anchors in the cloud
_spatialAnchorManager.DestroySession();
RemoveAllAnchorGameObjects();
Debug.Log("ASA - Stopped Session and removed all Anchor Objects");
}
앵커 찾기
이제 앵커를 만든 올바른 위치와 회전으로 다시 앵커를 찾으려고 시도할 것입니다. 이를 위해서는 세션을 시작하고 지정된 조건에 맞는 앵커를 찾는 Watcher
를 만들어야 합니다. 조건으로 이전에 만든 앵커의 ID를 피드에 제공합니다. LocateAnchor()
메서드를 만들고 SpatialAnchorManager
를 사용하여 Watcher
를 만들어 보겠습니다. 앵커 ID 사용 이외의 찾기 전략은 앵커 찾기 전략을 참조하세요.
/// <summary>
/// Looking for anchors with ID in _createdAnchorIDs
/// </summary>
private void LocateAnchor()
{
if (_createdAnchorIDs.Count > 0)
{
//Create watcher to look for all stored anchor IDs
Debug.Log($"ASA - Creating watcher to look for {_createdAnchorIDs.Count} spatial anchors");
AnchorLocateCriteria anchorLocateCriteria = new AnchorLocateCriteria();
anchorLocateCriteria.Identifiers = _createdAnchorIDs.ToArray();
_spatialAnchorManager.Session.CreateWatcher(anchorLocateCriteria);
Debug.Log($"ASA - Watcher created!");
}
}
감시자가 시작되면 지정된 조건에 맞는 앵커를 찾았을 때 콜백을 실행합니다. 먼저 감시자가 앵커를 찾았을 때 호출되도록 구성할 SpatialAnchorManager_AnchorLocated()
라는 앵커 위치 메서드를 만들어 보겠습니다. 이 메서드는 시각적 GameObject
를 만들고 여기에 네이티브 앵커 구성 요소를 연결합니다. 네이티브 앵커 구성 요소는 GameObject
의 올바른 위치와 회전이 설정되었는지 확인합니다.
만들기 프로세스와 유사하게 앵커는 GameObject에 연결됩니다. 이 GameObject는 공간 앵커가 작동하기 위해 장면에 표시될 필요가 없습니다. 이 자습서의 목적을 위해 각 앵커를 찾으면 파란색 큐브로 시각화합니다. 공유 좌표계를 설정하기 위해 앵커만 사용하는 경우 만들어진 GameObject를 시각화할 필요가 없습니다.
/// <summary>
/// Callback when an anchor is located
/// </summary>
/// <param name="sender">Callback sender</param>
/// <param name="args">Callback AnchorLocatedEventArgs</param>
private void SpatialAnchorManager_AnchorLocated(object sender, AnchorLocatedEventArgs args)
{
Debug.Log($"ASA - Anchor recognized as a possible anchor {args.Identifier} {args.Status}");
if (args.Status == LocateAnchorStatus.Located)
{
//Creating and adjusting GameObjects have to run on the main thread. We are using the UnityDispatcher to make sure this happens.
UnityDispatcher.InvokeOnAppThread(() =>
{
// Read out Cloud Anchor values
CloudSpatialAnchor cloudSpatialAnchor = args.Anchor;
//Create GameObject
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.blue;
// Link to Cloud Anchor
anchorGameObject.AddComponent<CloudNativeAnchor>().CloudToNative(cloudSpatialAnchor);
_foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
});
}
}
이제 감시자가 앵커를 찾으면 SpatialAnchorManager_AnchorLocated()
메서드가 호출되도록 SpatialAnchorManager
에서 AnchorLocated 콜백을 구독할 예정입니다.
// Start is called before the first frame update
void Start()
{
_spatialAnchorManager = GetComponent<SpatialAnchorManager>();
_spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - Debug: {args.Message}");
_spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - Error: {args.ErrorMessage}");
_spatialAnchorManager.AnchorLocated += SpatialAnchorManager_AnchorLocated;
}
마지막으로 앵커 찾기를 포함하도록 LongTap()
메서드를 확장해 보겠습니다. 앱 개요에 설명된 대로 IsSessionStarted
부울을 사용하여 모든 앵커를 찾을지 아니면 모든 앵커를 제거할지 결정합니다.
/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
if (_spatialAnchorManager.IsSessionStarted)
{
// Stop Session and remove all GameObjects. This does not delete the Anchors in the cloud
_spatialAnchorManager.DestroySession();
RemoveAllAnchorGameObjects();
Debug.Log("ASA - Stopped Session and removed all Anchor Objects");
}
else
{
//Start session and search for all Anchors previously created
await _spatialAnchorManager.StartSessionAsync();
LocateAnchor();
}
}
사용해 보기 #2
이제 앱에서 앵커 만들기 및 찾기를 지원합니다. Visual Studio를 사용하여 배포 및 디버그에 따라 Unity에서 앱을 빌드하고 Visual Studio에서 배포합니다.
HoloLens가 인터넷에 연결되어 있는지 확인하세요. 앱이 시작되고 made with Unity 메시지가 사라지면 주변을 짧게 탭합니다. 만들 앵커의 위치와 회전을 표시하기 위해 흰색 큐브가 나타나야 합니다. 앵커 만들기 프로세스가 자동으로 호출됩니다. 주변을 천천히 둘러보면서 환경 데이터를 캡처하고 있습니다. 충분한 환경 데이터가 수집되면 앱은 지정된 위치에 앵커를 만들려고 시도합니다. 앵커 만들기 프로세스가 완료되면 큐브가 녹색으로 바뀝니다. Visual Studio에서 디버그 로그를 확인하여 모든 것이 의도한 대로 작동하는지 확인합니다.
장면에서 모든 GameObjects
를 제거하고 공간 앵커 세션을 중지하려면 길게 누릅니다.
장면이 지워지면 다시 길게 누르면 세션이 시작되고 이전에 만든 앵커를 찾을 수 있습니다. 발견되면 고정 위치 및 회전에서 파란색 큐브로 시각화됩니다. 이러한 앵커(만료되지 않은 한)는 올바른 앵커 ID가 있고 공간 앵커 리소스에 액세스할 수 있는 한 지원되는 모든 디바이스에서 찾을 수 있습니다.
앵커 삭제
지금 Microsoft 앱은 앵커를 만들고 찾을 수 있습니다. GameObjects
를 삭제하는 동안 클라우드의 앵커는 삭제하지 않습니다. 기존 앵커를 탭하면 클라우드에서도 삭제하는 기능을 추가해 보겠습니다.
GameObject
를 수신하는 DeleteAnchor
메서드를 추가해 보겠습니다. 그런 다음 개체의 CloudNativeAnchor
구성 요소와 함께 SpatialAnchorManager
를 사용하여 클라우드에서 앵커 삭제를 요청합니다.
/// <summary>
/// Deleting Cloud Anchor attached to the given GameObject and deleting the GameObject
/// </summary>
/// <param name="anchorGameObject">Anchor GameObject that is to be deleted</param>
private async void DeleteAnchor(GameObject anchorGameObject)
{
CloudNativeAnchor cloudNativeAnchor = anchorGameObject.GetComponent<CloudNativeAnchor>();
CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
Debug.Log($"ASA - Deleting cloud anchor: {cloudSpatialAnchor.Identifier}");
//Request Deletion of Cloud Anchor
await _spatialAnchorManager.DeleteAnchorAsync(cloudSpatialAnchor);
//Remove local references
_createdAnchorIDs.Remove(cloudSpatialAnchor.Identifier);
_foundOrCreatedAnchorGameObjects.Remove(anchorGameObject);
Destroy(anchorGameObject);
Debug.Log($"ASA - Cloud anchor deleted!");
}
ShortTap
에서 이 메서드를 호출하려면 탭이 기존의 보이는 앵커 근처에 있었는지 확인할 수 있어야 합니다. 이를 처리하는 도우미 메서드를 만들어 봅시다.
using System.Linq;
/// <summary>
/// Returns true if an Anchor GameObject is within 15cm of the received reference position
/// </summary>
/// <param name="position">Reference position</param>
/// <param name="anchorGameObject">Anchor GameObject within 15cm of received position. Not necessarily the nearest to this position. If no AnchorObject is within 15cm, this value will be null</param>
/// <returns>True if a Anchor GameObject is within 15cm</returns>
private bool IsAnchorNearby(Vector3 position, out GameObject anchorGameObject)
{
anchorGameObject = null;
if (_foundOrCreatedAnchorGameObjects.Count <= 0)
{
return false;
}
//Iterate over existing anchor gameobjects to find the nearest
var (distance, closestObject) = _foundOrCreatedAnchorGameObjects.Aggregate(
new Tuple<float, GameObject>(Mathf.Infinity, null),
(minPair, gameobject) =>
{
Vector3 gameObjectPosition = gameobject.transform.position;
float distance = (position - gameObjectPosition).magnitude;
return distance < minPair.Item1 ? new Tuple<float, GameObject>(distance, gameobject) : minPair;
});
if (distance <= 0.15f)
{
//Found an anchor within 15cm
anchorGameObject = closestObject;
return true;
}
else
{
return false;
}
}
이제 DeleteAnchor
호출을 포함하도록 ShortTap
메서드를 확장할 수 있습니다.
/// <summary>
/// Called when a user is air tapping for a short time
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
await _spatialAnchorManager.StartSessionAsync();
if (!IsAnchorNearby(handPosition, out GameObject anchorGameObject))
{
//No Anchor Nearby, start session and create an anchor
await CreateAnchor(handPosition);
}
else
{
//Delete nearby Anchor
DeleteAnchor(anchorGameObject);
}
}
사용해보기 #3
Visual Studio를 사용하여 배포 및 디버그에 따라 Unity에서 앱을 빌드하고 Visual Studio에서 배포합니다.
손으로 탭하는 제스처의 위치는 이 앱에서 손의 중심이며 손가락 끝이 아닙니다.
만들기(녹색) 또는 위치(파란색) 앵커를 탭하면 계정에서 이 앵커를 제거하라는 요청이 공간 앵커 서비스로 전송됩니다. 모든 앵커를 검색하려면 세션을 중지(길게 누름)하고 세션을 다시 시작(길게 누름)합니다. 삭제된 앵커는 더 이상 찾을 수 없습니다.
모두 통합
모든 요소를 하나로 합치고 나면 다음과 같은 AzureSpatialAnchorsScript
클래스 파일이 완성됩니다. 이 클래스를 참조로 사용하여 여러분의 파일과 비교하고, 남은 차이점이 있으면 발견할 수 있습니다.
참고 항목
스크립트에 [RequireComponent(typeof(SpatialAnchorManager))]
가 포함되었음을 알 수 있습니다. 이를 통해 Unity는 AzureSpatialAnchorsScript
를 첨부한 GameObject에 SpatialAnchorManager
도 첨부되었는지 확인합니다.
using Microsoft.Azure.SpatialAnchors;
using Microsoft.Azure.SpatialAnchors.Unity;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.XR;
[RequireComponent(typeof(SpatialAnchorManager))]
public class AzureSpatialAnchorsScript : MonoBehaviour
{
/// <summary>
/// Used to distinguish short taps and long taps
/// </summary>
private float[] _tappingTimer = { 0, 0 };
/// <summary>
/// Main interface to anything Spatial Anchors related
/// </summary>
private SpatialAnchorManager _spatialAnchorManager = null;
/// <summary>
/// Used to keep track of all GameObjects that represent a found or created anchor
/// </summary>
private List<GameObject> _foundOrCreatedAnchorGameObjects = new List<GameObject>();
/// <summary>
/// Used to keep track of all the created Anchor IDs
/// </summary>
private List<String> _createdAnchorIDs = new List<String>();
// <Start>
// Start is called before the first frame update
void Start()
{
_spatialAnchorManager = GetComponent<SpatialAnchorManager>();
_spatialAnchorManager.LogDebug += (sender, args) => Debug.Log($"ASA - Debug: {args.Message}");
_spatialAnchorManager.Error += (sender, args) => Debug.LogError($"ASA - Error: {args.ErrorMessage}");
_spatialAnchorManager.AnchorLocated += SpatialAnchorManager_AnchorLocated;
}
// </Start>
// <Update>
// Update is called once per frame
void Update()
{
//Check for any air taps from either hand
for (int i = 0; i < 2; i++)
{
InputDevice device = InputDevices.GetDeviceAtXRNode((i == 0) ? XRNode.RightHand : XRNode.LeftHand);
if (device.TryGetFeatureValue(CommonUsages.primaryButton, out bool isTapping))
{
if (!isTapping)
{
//Stopped Tapping or wasn't tapping
if (0f < _tappingTimer[i] && _tappingTimer[i] < 1f)
{
//User has been tapping for less than 1 sec. Get hand position and call ShortTap
if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
{
ShortTap(handPosition);
}
}
_tappingTimer[i] = 0;
}
else
{
_tappingTimer[i] += Time.deltaTime;
if (_tappingTimer[i] >= 2f)
{
//User has been air tapping for at least 2sec. Get hand position and call LongTap
if (device.TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 handPosition))
{
LongTap();
}
_tappingTimer[i] = -float.MaxValue; // reset the timer, to avoid retriggering if user is still holding tap
}
}
}
}
}
// </Update>
// <ShortTap>
/// <summary>
/// Called when a user is air tapping for a short time
/// </summary>
/// <param name="handPosition">Location where tap was registered</param>
private async void ShortTap(Vector3 handPosition)
{
await _spatialAnchorManager.StartSessionAsync();
if (!IsAnchorNearby(handPosition, out GameObject anchorGameObject))
{
//No Anchor Nearby, start session and create an anchor
await CreateAnchor(handPosition);
}
else
{
//Delete nearby Anchor
DeleteAnchor(anchorGameObject);
}
}
// </ShortTap>
// <LongTap>
/// <summary>
/// Called when a user is air tapping for a long time (>=2 sec)
/// </summary>
private async void LongTap()
{
if (_spatialAnchorManager.IsSessionStarted)
{
// Stop Session and remove all GameObjects. This does not delete the Anchors in the cloud
_spatialAnchorManager.DestroySession();
RemoveAllAnchorGameObjects();
Debug.Log("ASA - Stopped Session and removed all Anchor Objects");
}
else
{
//Start session and search for all Anchors previously created
await _spatialAnchorManager.StartSessionAsync();
LocateAnchor();
}
}
// </LongTap>
// <RemoveAllAnchorGameObjects>
/// <summary>
/// Destroys all Anchor GameObjects
/// </summary>
private void RemoveAllAnchorGameObjects()
{
foreach (var anchorGameObject in _foundOrCreatedAnchorGameObjects)
{
Destroy(anchorGameObject);
}
_foundOrCreatedAnchorGameObjects.Clear();
}
// </RemoveAllAnchorGameObjects>
// <IsAnchorNearby>
/// <summary>
/// Returns true if an Anchor GameObject is within 15cm of the received reference position
/// </summary>
/// <param name="position">Reference position</param>
/// <param name="anchorGameObject">Anchor GameObject within 15cm of received position. Not necessarily the nearest to this position. If no AnchorObject is within 15cm, this value will be null</param>
/// <returns>True if a Anchor GameObject is within 15cm</returns>
private bool IsAnchorNearby(Vector3 position, out GameObject anchorGameObject)
{
anchorGameObject = null;
if (_foundOrCreatedAnchorGameObjects.Count <= 0)
{
return false;
}
//Iterate over existing anchor gameobjects to find the nearest
var (distance, closestObject) = _foundOrCreatedAnchorGameObjects.Aggregate(
new Tuple<float, GameObject>(Mathf.Infinity, null),
(minPair, gameobject) =>
{
Vector3 gameObjectPosition = gameobject.transform.position;
float distance = (position - gameObjectPosition).magnitude;
return distance < minPair.Item1 ? new Tuple<float, GameObject>(distance, gameobject) : minPair;
});
if (distance <= 0.15f)
{
//Found an anchor within 15cm
anchorGameObject = closestObject;
return true;
}
else
{
return false;
}
}
// </IsAnchorNearby>
// <CreateAnchor>
/// <summary>
/// Creates an Azure Spatial Anchor at the given position rotated towards the user
/// </summary>
/// <param name="position">Position where Azure Spatial Anchor will be created</param>
/// <returns>Async Task</returns>
private async Task CreateAnchor(Vector3 position)
{
//Create Anchor GameObject. We will use ASA to save the position and the rotation of this GameObject.
if (!InputDevices.GetDeviceAtXRNode(XRNode.Head).TryGetFeatureValue(CommonUsages.devicePosition, out Vector3 headPosition))
{
headPosition = Vector3.zero;
}
Quaternion orientationTowardsHead = Quaternion.LookRotation(position - headPosition, Vector3.up);
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.transform.position = position;
anchorGameObject.transform.rotation = orientationTowardsHead;
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
//Add and configure ASA components
CloudNativeAnchor cloudNativeAnchor = anchorGameObject.AddComponent<CloudNativeAnchor>();
await cloudNativeAnchor.NativeToCloud();
CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
cloudSpatialAnchor.Expiration = DateTimeOffset.Now.AddDays(3);
//Collect Environment Data
while (!_spatialAnchorManager.IsReadyForCreate)
{
float createProgress = _spatialAnchorManager.SessionStatus.RecommendedForCreateProgress;
Debug.Log($"ASA - Move your device to capture more environment data: {createProgress:0%}");
}
Debug.Log($"ASA - Saving cloud anchor... ");
try
{
// Now that the cloud spatial anchor has been prepared, we can try the actual save here.
await _spatialAnchorManager.CreateAnchorAsync(cloudSpatialAnchor);
bool saveSucceeded = cloudSpatialAnchor != null;
if (!saveSucceeded)
{
Debug.LogError("ASA - Failed to save, but no exception was thrown.");
return;
}
Debug.Log($"ASA - Saved cloud anchor with ID: {cloudSpatialAnchor.Identifier}");
_foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
_createdAnchorIDs.Add(cloudSpatialAnchor.Identifier);
anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.green;
}
catch (Exception exception)
{
Debug.Log("ASA - Failed to save anchor: " + exception.ToString());
Debug.LogException(exception);
}
}
// </CreateAnchor>
// <LocateAnchor>
/// <summary>
/// Looking for anchors with ID in _createdAnchorIDs
/// </summary>
private void LocateAnchor()
{
if (_createdAnchorIDs.Count > 0)
{
//Create watcher to look for all stored anchor IDs
Debug.Log($"ASA - Creating watcher to look for {_createdAnchorIDs.Count} spatial anchors");
AnchorLocateCriteria anchorLocateCriteria = new AnchorLocateCriteria();
anchorLocateCriteria.Identifiers = _createdAnchorIDs.ToArray();
_spatialAnchorManager.Session.CreateWatcher(anchorLocateCriteria);
Debug.Log($"ASA - Watcher created!");
}
}
// </LocateAnchor>
// <SpatialAnchorManagerAnchorLocated>
/// <summary>
/// Callback when an anchor is located
/// </summary>
/// <param name="sender">Callback sender</param>
/// <param name="args">Callback AnchorLocatedEventArgs</param>
private void SpatialAnchorManager_AnchorLocated(object sender, AnchorLocatedEventArgs args)
{
Debug.Log($"ASA - Anchor recognized as a possible anchor {args.Identifier} {args.Status}");
if (args.Status == LocateAnchorStatus.Located)
{
//Creating and adjusting GameObjects have to run on the main thread. We are using the UnityDispatcher to make sure this happens.
UnityDispatcher.InvokeOnAppThread(() =>
{
// Read out Cloud Anchor values
CloudSpatialAnchor cloudSpatialAnchor = args.Anchor;
//Create GameObject
GameObject anchorGameObject = GameObject.CreatePrimitive(PrimitiveType.Cube);
anchorGameObject.transform.localScale = Vector3.one * 0.1f;
anchorGameObject.GetComponent<MeshRenderer>().material.shader = Shader.Find("Legacy Shaders/Diffuse");
anchorGameObject.GetComponent<MeshRenderer>().material.color = Color.blue;
// Link to Cloud Anchor
anchorGameObject.AddComponent<CloudNativeAnchor>().CloudToNative(cloudSpatialAnchor);
_foundOrCreatedAnchorGameObjects.Add(anchorGameObject);
});
}
}
// </SpatialAnchorManagerAnchorLocated>
// <DeleteAnchor>
/// <summary>
/// Deleting Cloud Anchor attached to the given GameObject and deleting the GameObject
/// </summary>
/// <param name="anchorGameObject">Anchor GameObject that is to be deleted</param>
private async void DeleteAnchor(GameObject anchorGameObject)
{
CloudNativeAnchor cloudNativeAnchor = anchorGameObject.GetComponent<CloudNativeAnchor>();
CloudSpatialAnchor cloudSpatialAnchor = cloudNativeAnchor.CloudAnchor;
Debug.Log($"ASA - Deleting cloud anchor: {cloudSpatialAnchor.Identifier}");
//Request Deletion of Cloud Anchor
await _spatialAnchorManager.DeleteAnchorAsync(cloudSpatialAnchor);
//Remove local references
_createdAnchorIDs.Remove(cloudSpatialAnchor.Identifier);
_foundOrCreatedAnchorGameObjects.Remove(anchorGameObject);
Destroy(anchorGameObject);
Debug.Log($"ASA - Cloud anchor deleted!");
}
// </DeleteAnchor>
}
다음 단계
이 자습서에서는 Unity를 사용하여 HoloLens용 기본 Spatial Anchors 애플리케이션을 구현하는 방법을 알아보았습니다. 새 Android 앱에서 Azure Spatial Anchors를 사용하는 방법을 자세히 알아보려면 다음 자습서를 계속 진행하세요.