일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 |
- AR 던지기 게임
- ar game
- AR 게임
- ray tracing
- 평면에 물체 던지기
- 레이 트레이싱
- Ray Tracing in One Weekend
- AR Foundation Throwing
- AR Throwing Game
- 렌더링
- Today
- Total
요리조리
[Unity][AR Foundation] 쓰레기 던지기 미니게임 (AR Throwing) 본문
구현 순서
0. 유니티 세팅
1. 평면 인식
2. 평면 위로 쓰레기 던지기
3. 평면에 쓰레기통 고정시키기
4. 점수 시스템
5. 추가 기능들 (게임 재시작, 게임 오버)
0. Settings
AR 프로젝트 세팅과 빌드 세팅을 먼저 진행한다.
0.1 AR Project Setting
참고: https://coding-of-today.tistory.com/10
• Package Manager에서 AR Foundation, ARCore XR Plugin 패키지 Install
• [Hierarchy]에서 XR -> AR Session과 AR Session Origin 객체 추가
- AR Session Origin의 AR Camera의 Facing Direction > World(후면 카메라)
- Main Camera 삭제 -> AR Camera의 Tag > MainCamera
0.2. Build Setting
File > Build Settings
• [Add Open Scenes] > Android로 Switch Platform
• Player Settings > Player > Other Settings > Auto Graphics API 체크 취소 > Graphics APIs의 Vulkan 엔진 삭제(-), OpenGLES3 만 남기기! (AR에서는 Vulkan 엔진 지원 X)
• Minimum API Level(AR Core가 최소로 지원하는 레벨) > Android 7.0 ‘Nougat’ (API Level 24)
• > XR Plug-in Management(내부적으로 어떤 SDK 사용할 건지) > AR Core 체크!
+ 나무쌓기
2019년 구글 정책의 변경으로 64비트 앱만 호환가능하기 때문에
• Player Settings > Player > Other Settings > Configuration의 Scripting Backend를 IL2CPP로 변경, ARM64를 (추가로) 체크
• 빌드 세팅에서 Android로 Switch Platform해준다.
+ Build Failure
• Key 추가
https://blog.naver.com/lyw94k/221290872599
https://appletreeworkplace.tistory.com/6
그 후 블록 쌓기 블로그 따라함!
https://seongju0007.tistory.com/167
1. 평면 인식
• AR Camera의 Clipping Planes: 100으로 변경, AR Camera Manager의 facing Direction: World(후면 카메라)
• AR Session Origin에 AR Plane Manager(실제 세계의 평면 인식)과 AR Point Cloud Manager(특징점 생성해서 인식) 컴포넌트 추가
• 각 컴포넌트의 Prefab에는 각각 [Hierarchy] > 마우스 우클릭 > XR > AR Default Plane과 Point Cloud 객체를 생성 후 Prefab화 해서 넣어 연결
- AR Plane Manager의 Detection Mode(뭘 인식할 건지): Horizontal(지면)/Vertical(벽면)/Everything(둘 다)
2. 인식한 평면 위에 쓰레기 던지기
• <TouchHelper>, <ThrowController>, <FeedController> 스트립트 작성
<TouchHelper>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// static: TouchHelper will be used frequently
public static class TouchHelper
{
// Use mouse click when executed in Unity Editor(for testing)
#if UNITY_EDITOR
public static bool Touch2 => Input.GetMouseButtonDown(1); // If mouse right is clicked
public static bool IsDown => Input.GetMouseButtonDown(0); // If mouse left is clicked
public static bool IsUp => Input.GetMouseButtonUp(0);
public static Vector2 TouchPosition => Input.mousePosition;
// Use touch(tab) when executed in actual devices
#else
public static bool Touch2 => Input.touchCount == 2 && (Input.GetTouch(1).phase == TouchPhase.Began); // if touched by two fingers
public static bool IsDown => Input.GetTouch(0).phase == TouchPhase.Began; // true if a touch has just begun (by one finger)
public static bool IsUp => Input.GetTouch(0).phase == TouchPhase.Ended; // true if a touch has ended or been released
public static Vector2 TouchPosition => Input.GetTouch(0).position;
#endif
}
<ThrowController>
// 추가한 기능들 (#ES)
// 1. 한 번에 하나의 오브젝트만 들 수 있게끔 (기존에 이미 물체가 holding되어 있는데도 또 두 손가락으로 터치하면 새로운 물체 생성됨, 기존 물체와 부딪혀서 튕겨나감)
// 2. 한 번 떨어진 물체는 다시 움직일 수 없게끔 (한 번 떨어진 물체는 더 이상 충돌 불가하게 수정)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ThrowController : MonoBehaviour
{
private const float CameraDistance = 7.5f;
public float positionY = 0.4f;
public GameObject[] prefab;
protected Camera mainCamera;
protected GameObject HoldingObject;
protected Vector3 InputPosition;
// #ES1: Make sure to hold one object at a time (Prevent newly held object to hit the previous held one)
public static bool isAlreadyTouched2 = false;
void Start()
{
mainCamera = Camera.main; // Get the main camera of the scene
Reset();
}
void Update()
{
// ES: Use mouse click if executed in Unity Editor(for testing)
#if !UNITY_EDITOR
if (Input.touchCount == 0) return;
#endif
InputPosition = TouchHelper.TouchPosition; // If touched, get the touched position
// touched with 2 fingers: Create new object to throw
if(TouchHelper.Touch2)
{
if(!isAlreadyTouched2) { // #ES1
Reset();
}
return;
}
// dragged: Throw the object
if(HoldingObject) // Check if the object is not null
{
// If a touch input has ended/been released
if(TouchHelper.IsUp)
{
isAlreadyTouched2 = false; // #ES1
OnPut(InputPosition); // Throw the object with force
// #ES2 Trial1: Disable all kinds of collision for the thrown object
// 평면에 닿자마자 충돌 불가로 변경 => 충돌을 아예 불가능하게 해버리면 평면에 쌓이지 않게 됨
// Collider[] colliders = HoldingObject.GetComponentsInChildren<Collider>();
// foreach (Collider collider in colliders)
// {
// collider.enabled = false;
// }
// the object is no longer being held
HoldingObject = null;
return;
}
Move(InputPosition); // Move the object to the touched position
return;
}
if (!TouchHelper.IsDown) return;
// Check if the casted ray hits the object with tag "ThrownObject"(object thrown by the player)
if(Physics.Raycast(mainCamera.ScreenPointToRay(InputPosition), out var hits, mainCamera.farClipPlane))
{
if(hits.transform.gameObject.tag.Equals("ThrownObject"))
{
// Assign the object that was hit
HoldingObject = hits.transform.gameObject;
OnHold();
}
}
}
// Stop holding the object
protected virtual void OnPut(Vector3 pos)
{
HoldingObject.GetComponent<Rigidbody>().useGravity = true; // Enable gravity for the held object
HoldingObject.transform.SetParent(null);
// Rigidbody rb = HoldingObject.GetComponent<Rigidbody>();
// rb.isKinematic = true; // Make the Rigidbody kinematic to fix its position
}
// Move the held object towards the target position based on the camera's perspective
private void Move(Vector3 pos)
{
pos.z = mainCamera.nearClipPlane * CameraDistance;
HoldingObject.transform.position = Vector3.Lerp(HoldingObject.transform.position, mainCamera.ScreenToWorldPoint(pos), Time.deltaTime * 7f);
}
// Hold the object by positioning it in the game world based on the camera's viewpoint
protected virtual void OnHold()
{
HoldingObject.GetComponent<Rigidbody>().useGravity = false; // Hold the object by disabling gravity for it
HoldingObject.transform.SetParent(mainCamera.transform); // Make the object as a child of main camera (to move and rotate relative to the camera)
HoldingObject.transform.rotation = Quaternion.identity;
HoldingObject.transform.position = mainCamera.ViewportToWorldPoint(new Vector3(0.5f, positionY, mainCamera.nearClipPlane * CameraDistance));
}
// Create a new object and reset it to its initial state
private void Reset()
{
var pos = mainCamera.ViewportToWorldPoint(new Vector3(0.5f, positionY, mainCamera.nearClipPlane * CameraDistance));
var obj = Instantiate(prefab[0], pos, Quaternion.identity, mainCamera.transform);
var rigidbody = obj.GetComponent<Rigidbody>();
rigidbody.useGravity = false;
rigidbody.velocity = Vector3.zero;
rigidbody.angularVelocity = Vector3.zero;
isAlreadyTouched2 = true; // #ES1
}
}
}
ThrowController를 상속받은 <FeedController>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class FeedController : ThrowController
{
private Vector2 _inputPositionPivot;
// Throw the feed with force
protected override void OnPut(Vector3 pos)
{
var rigidbody = HoldingObject.GetComponent<Rigidbody>();
rigidbody.useGravity = true;
var direction = mainCamera.transform.TransformDirection(Vector3.forward).normalized;
var delta = (pos.y - _inputPositionPivot.y) * 100f / Screen.height;
rigidbody.AddForce((direction + Vector3.up) * 4.5f * delta);
HoldingObject.transform.SetParent(null);
_inputPositionPivot.y = pos.y;
}
protected override void OnHold()
{
_inputPositionPivot = InputPosition;
}
}
• 던질 물체의 ‘프리팹’을 [Hierarchy]에 추가, 해당 프리팹의 Tag > ThrownObject로 설정 (해당 태그 새롭게 생성 후)
• 프리팹에 Rigidbody(중력 작용 가능하게) 컴포넌트 추가
• (Collider 없는 경우: Mesh/Square/Sphere Collider(충돌 가능하게) 컴포넌트 추가)
n Mesh Collider와 Rigidbody를 같이 쓰면 에러감 남! (is kinetic을 체크하라고 하는데 그렇게 되면 외부 힘의 영향을 받지 않고 고정되므로 여기서는 적합X)
• AR Session Origin에 <Feed Controller 스크립트> 컴포넌트 추가
• Prefab 하나 추가(+) > Element 0에 던질 물체의 프리팹 연결
+ 던졌는데 뚝뚝 떨어진다?: Rigidbody의 Mass를 1로 설정 (너무 크면 무거워서 그럼)
3. 평면 위에 고정된 쓰레기통 위치시키기
참고: https://www.youtube.com/watch?v=KqzlGApWPEA
• AR Session Origin에 AR Raycast Manager 컴포넌트 추가
• 쓰레기통 프리팹에 Rigidbody, Box Collider 추가
• Create Empty > 이름을 PlacementIndicator로 변경 > 그 하위에 3D Object의 Quad 추가 > Transform 조정
• Create Empty > 이름을 PlacementController라 변경 > <ARPlacement 스크립트> 컴포넌트 추가
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.ARFoundation;
using UnityEngine.XR.ARSubsystems;
public class ARPlacement : MonoBehaviour
{
public GameObject arObjectToSpawn; // bowl to place on the plane
public GameObject placementIndicator;
private GameObject spawnedObject;
private Pose PlacementPose;
private ARRaycastManager aRRaycastManager;
private bool placementPoseIsValid = false;
// #ES: No more plane showing after he bowl is placed
private ARPlaneManager arPlaneManager;
void Start()
{
aRRaycastManager = FindObjectOfType<ARRaycastManager>();
arPlaneManager = FindObjectOfType<ARPlaneManager>();
}
// need to update placement indicator, placement pose and spawn
void Update()
{
// Create the bowl instance and place it on the plane only once
if(spawnedObject == null && placementPoseIsValid && Input.touchCount > 0 && Input.GetTouch(0).phase == TouchPhase.Began)
{
ARPlaceObject();
}
UpdatePlacementPose();
UpdatePlacementIndicator();
}
void UpdatePlacementIndicator()
{
if(spawnedObject == null && placementPoseIsValid)
{
placementIndicator.SetActive(true);
placementIndicator.transform.SetPositionAndRotation(PlacementPose.position, PlacementPose.rotation);
}
else
{
placementIndicator.SetActive(false);
// Stop showing perceived planes
// foreach (var plane in arPlaneManager.trackables) {
// // #Trial1: 오브젝트가 생성되면 Plane 인스턴스 생성을 멈춤 => 기존의 평면도 사라지게 됨
// plane.gameObject.SetActive(false);
// }
}
}
void UpdatePlacementPose()
{
// Get the position of the screen center
var screenCenter = Camera.main.ViewportToScreenPoint(new Vector3(0.5f, 0.5f));
var hits = new List<ARRaycastHit>();
aRRaycastManager.Raycast(screenCenter, hits, TrackableType.Planes);
placementPoseIsValid = hits.Count > 0;
if(placementPoseIsValid)
{
PlacementPose = hits[0].pose; // Get the very first hit
}
}
// Create the new instance of the bowl
void ARPlaceObject()
{
spawnedObject = Instantiate(arObjectToSpawn, PlacementPose.position, PlacementPose.rotation);
var rigidbody = spawnedObject.GetComponent<Rigidbody>();
rigidbody.isKinematic = true;
}
}
• AR Placement 컴포넌트의 Ar Object To Spawn에는 쓰레기통 프리팹 연결, Placement Indicator에는 PlacementIndicator 연결
쓰레기통 놓을 위치 정할 때 target icon 애니메이션
• Assets 폴더 하위에 Textures 폴더 생성 > target icon 이미지 안에 위치
• Assets 폴더 하위에 Materials 폴더 생성 > 안에 새 Mateirial(이름: Indicator) 생성 > Indicator Material의 Shader를 Unlit/Transparent로 변경
• PlacementIndicator아래의 Quad의 Materials > Element 0에 Indicator 연결
추가한 기능들 ① 한 번에 하나의 오브젝트만 들 수 있게 제한 ② 한 번 던진 오브젝트는 더 이상 움직이지 않게 (실패) ③ 쓰레기통을 평면 위 지점 중, 카메라로부터 고정된 거리에 두기 - https://www.youtube.com/watch?v=KqzlGApWPEA ④ 쓰레기통의 위치는 계속 고정되게 하기 (충돌은 가능하지만 움직이지는 않게) |
4. 쓰레기통과 쓰레기의 충돌 이벤트 처리: UI에 들어간 횟수 카운팅
참고:
https://ruinchan.tistory.com/28
쓰레기통 프리팹 태그를 “Container”로 설정
• 쓰레기통에 Box Collider 추가 > 크기 및 위치 조정(박스 바닥 부근에 위치한 얇은 박스) > Is Trigger 체크하기!
• Heirarchy에 UI > Canvas 추가
• Canvas 아래에 UI > Legacy > Text 추가, 이름을 ‘SuccessScoreText’로 변경
• <ScoreManager> 스크립트 작성 > 쓰레기통 프리팹에 컴포넌트로 추가
- OnCollisionEntered를 사용하게 되면, 던진 쓰레기가 쓰레기통과 충돌한 후 튕겨서 다시 충돌하는 것까지 모두 카운팅하게됨. 따라서 물리적인 충돌로 점수를 계산하는 건 비추!
- 비추하는 이유 2: 쓰레기통 바깥과 충돌한 후 튕겨 나가는 경우에도 점수를 카운팅하게 됨. 따라서 쓰레기통 안으로 들어간 경우에만 점수가 증가하도록 하자!
- 쓰레기통 프리팹에: 쓰레기통 안으로 들어갈 때만 충돌이 가능할 만한 위치와 크기의 투명 box collider를 추가, 해당 박스 콜라이더와 충돌한 경우에만 점수 1 증가!
• 스크립트 작성
<ScoreManager>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class ScoreManager : MonoBehaviour
{
private int currentScore = 0;
private int targetScore = 5;
private Text scoreText;
private void Awake()
{
scoreText = GameObject.Find("SuccessScoreText").GetComponent<Text>();
}
//(충돌이 일어나는 동안 점수가 무한 증가하지 않게 쓰레기통 안에 투명 box collider 추가)
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("ThrownObject"))
{
UpdateScoreText();
}
}
// Increase score by one for one collision
private void UpdateScoreText()
{
currentScore++;
scoreText.text = "성공: " + currentScore + "/" + targetScore;
}
}
5. 추가 기능들
5.1. 게임 재시작 버튼
참고: https://blog.naver.com/PostView.naver?blogId=harrison1995&logNo=221988980881&redirect=Dlog&widgetTypeCall=true&directAccess=false
• Create Empty > 이름 ESMinigameManager로 변경
- <ESMinigameManager> 스크립트 수정: 현재 씬을 재로딩하는 함수 추가
public void onClickRestartMinigame()
{
print("RESTART!");
// Load the minigame scene again
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
• Canvas 아래에 Button 추가 > 이름을 RestartButton으로 변경
- 버튼 클릭 시 위 함수 호출하기: On Click() > + > ESMinigameManager 오브젝트 연결 > onClickRestartMinigame() 함수 연결
5.2. 미니게임 성공 시: 성공 화면 보여주기
• Canvas 아래에 UI > Panel 추가, 이름을 GameoverPanel로 변경
• (Canvas는 활성화, GameoverPanel만 비활성화시키기!
• <ESMinigameManager> 스크립트에 함수 ‘()’ 추가: 게임오버 판넬을 활성화하는 함수
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
public class ESMinigameManager : MonoBehaviour
{
public GameObject gameoverPanel;
// Start is called before the first frame update
void Awake()
{
// When the scene is loaded, the gameover panel should be invisible
gameoverPanel = GameObject.Find("Canvas").transform.Find("GameoverPanel").gameObject;
gameoverPanel.SetActive(false);
}
public void onClickRestartMinigame()
{
print("RESTART!");
// Load the minigame scene again
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
}
public void showGameoverPanel()
{
print("GAME OVER!");
gameoverPanel.SetActive(true);
}
// Update is called once per frame
void Update()
{
}
}
https://prosto.tistory.com/164
• <ScoreManager> 스크립트에 함수 ‘checkScore()’ 추가: 목표 점수에 도달하면 게임 오버 함수를 호출하는 함수
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Collections;
public class ScoreManager : MonoBehaviour
{
private int currentScore = 0;
private int targetScore = 5;
private Text scoreText;
private void Awake()
{
scoreText = GameObject.Find("SuccessScoreText").GetComponent<Text>();
scoreText.text = "성공: " + currentScore + "/" + targetScore;
}
private void Update() {
checkScore();
}
//(충돌이 일어나는 동안 점수가 무한 증가하지 않게 쓰레기통 안에 투명 box collider 추가)
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("ThrownObject"))
{
UpdateScoreText();
}
}
// Increase score by one for one collision
private void UpdateScoreText()
{
currentScore++;
scoreText.text = "성공: " + currentScore + "/" + targetScore;
}
private void checkScore()
{
if(currentScore >= targetScore)
{
GameObject.Find("ESMinigameManager").GetComponent<ESMinigameManager>().showGameoverPanel();
}
}
}
+ 미니게임 가이드(안내) 텍스트 추가
• Canvas > PlayerGuideText 이름으로 텍스트 추가
• 스크립트 <MinigameManager>에서 안내 텍스트를 배열에 저장하는 함수, 주어진 인덱스를 가지고 배열에 저장된 텍스트를 읽어서 Ui에 보여주는 함수를 추가 -> 스크립트 <ThrowController>, <ARPlacement>에서 MinigameManager 스크립트를 find한 후, 타이밍에 딱딱 맞게 가이드 텍스트를 보여주는 함수를 호출하는 코드 추가
Trashcan 프리팹 설정들
결과 시연 영상
여유 있을 때 수정하면 좋을 점들
• Readability 개선: ‘성공 0/5’ 문구와, 다시하기 버튼을 평면 인식 > 쓰레기통 놓기 단계까지 수행한 후에 나타나게 한다
• 평면 인식하는 주황색 plane의 색깔을 좀 더 투명하게 변경한다.
• 그래픽 좀 더 예쁘게, 안내 텍스트는 박스 안에 보이게 디자인 변경