1vdlrwnsv1 님의 블로그
Unity, 밸브사의 Portal 게임을 클론 해보자잇 본문
https://github.com/daniel-ilett/portals-urp 코드 출처
https://www.youtube.com/watch?v=PkGjYig8avo
위 링크로 들어가 파일을 받고 내 프로젝트에 옮기면 뻘건색으로 셰이더가 깨진다
프로젝트 세팅에서 Graphics에서 Scriptable Render Pipeline Settings가 None으로 돼 있을텐데 UniverserRenderePipelineAsset으로 넣어준다 깨짐 해결
그러나 다른 메테리얼이 깨짐
상단바에 Window -> Rendering -> Render Pipeline Converter에서

Material Upgrade를 체크하고 innitialize And Convert를 누르고 Convert Assets를 눌러준다
깨짐 해결
이제 포탈 프로젝트를 까보자 일단 셰이더가 중요한거 같은데 봐도 모르겠다
깝치지말고 스크립트만 봐보자
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[RequireComponent(typeof(BoxCollider))]
public class Portal : MonoBehaviour
{
[field: SerializeField]
public Portal OtherPortal { get; private set; }
[SerializeField]
private Renderer outlineRenderer;
[field: SerializeField]
public Color PortalColour { get; private set; }
[SerializeField]
private LayerMask placementMask;
[SerializeField]
private Transform testTransform;
private List<PortalableObject> portalObjects = new List<PortalableObject>();
public bool IsPlaced { get; private set; } = false;
private Collider wallCollider;
// 컴포넌트들.
public Renderer Renderer { get; private set; }
private new BoxCollider collider;
private void Awake()
{
// BoxCollider와 Renderer 컴포넌트를 가져옴.
collider = GetComponent<BoxCollider>();
Renderer = GetComponent<Renderer>();
}
private void Start()
{
// 아웃라인 컬러를 포탈 컬러로 설정.
outlineRenderer.material.SetColor("_OutlineColour", PortalColour);
// 포탈을 비활성화 상태로 설정.
gameObject.SetActive(false);
}
private void Update()
{
// 다른 포탈이 배치되어 있으면 렌더러 활성화.
Renderer.enabled = OtherPortal.IsPlaced;
// 포탈에 들어간 모든 물체들에 대해 Warp 실행.
for (int i = 0; i < portalObjects.Count; ++i)
{
Vector3 objPos = transform.InverseTransformPoint(portalObjects[i].transform.position);
// 포탈에 들어간 물체의 z축 위치가 0보다 크면 포탈을 통과.
if (objPos.z > 0.0f)
{
portalObjects[i].Warp();
}
}
}
private void OnTriggerEnter(Collider other)
{
// 포탈에 들어온 물체 처리.
var obj = other.GetComponent<PortalableObject>();
if (obj != null)
{
portalObjects.Add(obj);
obj.SetIsInPortal(this, OtherPortal, wallCollider);
if(other.gameObject.layer == LayerMask.NameToLayer("Player"))
{
CameraMove cameraMove = other.GetComponent<CameraMove>();
if(cameraMove.currentVelocity.magnitude > 10f)
{
cameraMove.isInPortal = true;
}
}
}
}
private void OnTriggerExit(Collider other)
{
// 포탈을 나가는 물체 처리.
var obj = other.GetComponent<PortalableObject>();
if(portalObjects.Contains(obj))
{
portalObjects.Remove(obj);
obj.ExitPortal(wallCollider);
}
}
// 포탈을 배치할 수 있는지 확인 후, 배치가 가능하면 포탈을 배치.
public bool PlacePortal(Collider wallCollider, Vector3 pos, Quaternion rot)
{
testTransform.position = pos;
testTransform.rotation = rot;
testTransform.position -= testTransform.forward * 0.001f;
// 포탈 배치 전의 충돌을 수정.
FixOverhangs();
FixIntersects();
// 겹침이 없으면 포탈을 배치.
if (CheckOverlap())
{
this.wallCollider = wallCollider;
transform.position = testTransform.position;
transform.rotation = testTransform.rotation;
// 포탈을 활성화하고 배치 완료.
gameObject.SetActive(true);
IsPlaced = true;
return true;
}
return false;
}
// 포탈이 겹치지 않도록 하기 위해 포탈이 벽을 넘어가지 않도록 수정.
private void FixOverhangs()
{
var testPoints = new List<Vector3>
{
new Vector3(-1.1f, 0.0f, 0.1f),
new Vector3( 1.1f, 0.0f, 0.1f),
new Vector3( 0.0f, -2.1f, 0.1f),
new Vector3( 0.0f, 2.1f, 0.1f)
};
var testDirs = new List<Vector3>
{
Vector3.right,
-Vector3.right,
Vector3.up,
-Vector3.up
};
for(int i = 0; i < 4; ++i)
{
RaycastHit hit;
Vector3 raycastPos = testTransform.TransformPoint(testPoints[i]);
Vector3 raycastDir = testTransform.TransformDirection(testDirs[i]);
if(Physics.CheckSphere(raycastPos, 0.05f, placementMask))
{
break;
}
else if(Physics.Raycast(raycastPos, raycastDir, out hit, 2.1f, placementMask))
{
var offset = hit.point - raycastPos;
testTransform.Translate(offset, Space.World);
}
}
}
// 포탈이 벽과 겹치지 않도록 하기 위해 충돌을 수정.
private void FixIntersects()
{
var testDirs = new List<Vector3>
{
Vector3.right,
-Vector3.right,
Vector3.up,
-Vector3.up
};
var testDists = new List<float> { 1.1f, 1.1f, 2.1f, 2.1f };
for (int i = 0; i < 4; ++i)
{
RaycastHit hit;
Vector3 raycastPos = testTransform.TransformPoint(0.0f, 0.0f, -0.1f);
Vector3 raycastDir = testTransform.TransformDirection(testDirs[i]);
if (Physics.Raycast(raycastPos, raycastDir, out hit, testDists[i], placementMask))
{
var offset = (hit.point - raycastPos);
var newOffset = -raycastDir * (testDists[i] - offset.magnitude);
testTransform.Translate(newOffset, Space.World);
}
}
}
// 포탈이 다른 물체와 겹치지 않는지 확인.
private bool CheckOverlap()
{
var checkExtents = new Vector3(0.9f, 1.9f, 0.05f);
var checkPositions = new Vector3[]
{
testTransform.position + testTransform.TransformVector(new Vector3( 0.0f, 0.0f, -0.1f)),
testTransform.position + testTransform.TransformVector(new Vector3(-1.0f, -2.0f, -0.1f)),
testTransform.position + testTransform.TransformVector(new Vector3(-1.0f, 2.0f, -0.1f)),
testTransform.position + testTransform.TransformVector(new Vector3( 1.0f, -2.0f, -0.1f)),
testTransform.position + testTransform.TransformVector(new Vector3( 1.0f, 2.0f, -0.1f)),
testTransform.TransformVector(new Vector3(0.0f, 0.0f, 0.2f))
};
// 포탈이 벽과 겹치지 않는지 확인.
var intersections = Physics.OverlapBox(checkPositions[0], checkExtents, testTransform.rotation, placementMask);
if(intersections.Length > 1)
{
return false;
}
else if(intersections.Length == 1)
{
// 이전 포탈 위치와 겹치는 것은 허용.
if (intersections[0] != collider)
{
return false;
}
}
// 포탈의 모서리가 벽과 겹치지 않는지 확인.
bool isOverlapping = true;
for(int i = 1; i < checkPositions.Length - 1; ++i)
{
isOverlapping &= Physics.Linecast(checkPositions[i],
checkPositions[i] + checkPositions[checkPositions.Length - 1], placementMask);
}
return isOverlapping;
}
// 포탈을 제거.
public void RemovePortal()
{
gameObject.SetActive(false);
IsPlaced = false;
}
}
PortalCamera.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using RenderPipeline = UnityEngine.Rendering.RenderPipelineManager;
public class PortalCamera : MonoBehaviour
{
[SerializeField]
private Portal[] portals = new Portal[2]; // 포탈 객체 배열, 두 개의 포탈을 사용
[SerializeField]
private Camera portalCamera; // 포탈을 보여줄 카메라
[SerializeField]
private int iterations = 7; // 카메라 렌더링 반복 횟수
private RenderTexture tempTexture1; // 첫 번째 포탈 렌더링용 임시 텍스처
private RenderTexture tempTexture2; // 두 번째 포탈 렌더링용 임시 텍스처
private Camera mainCamera; // 메인 카메라 (현재 카메라)
private void Awake()
{
// 컴포넌트 초기화
mainCamera = GetComponent<Camera>();
// 화면 크기와 포맷에 맞게 렌더 텍스처 생성
tempTexture1 = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.ARGB32);
tempTexture2 = new RenderTexture(Screen.width, Screen.height, 24, RenderTextureFormat.ARGB32);
}
private void Start()
{
// 포탈의 렌더러의 재질에 텍스처 할당
portals[0].Renderer.material.mainTexture = tempTexture1;
portals[1].Renderer.material.mainTexture = tempTexture2;
}
private void OnEnable()
{
// 카메라 렌더링 시작 시 UpdateCamera 메서드를 호출하도록 이벤트 등록
RenderPipeline.beginCameraRendering += UpdateCamera;
}
private void OnDisable()
{
// 카메라 렌더링 시작 시 UpdateCamera 메서드 호출을 제거하는 이벤트 해제
RenderPipeline.beginCameraRendering -= UpdateCamera;
}
void UpdateCamera(ScriptableRenderContext SRC, Camera camera)
{
// 포탈이 배치되지 않았다면 렌더링을 수행하지 않음
if (!portals[0].IsPlaced || !portals[1].IsPlaced)
{
return;
}
// 첫 번째 포탈이 화면에 보이면 렌더링
if (portals[0].Renderer.isVisible)
{
portalCamera.targetTexture = tempTexture1; // 첫 번째 포탈 렌더링용 텍스처 설정
for (int i = iterations - 1; i >= 0; --i)
{
// 첫 번째 포탈을 통해 두 번째 포탈을 렌더링
RenderCamera(portals[0], portals[1], i, SRC);
}
}
// 두 번째 포탈이 화면에 보이면 렌더링
if(portals[1].Renderer.isVisible)
{
portalCamera.targetTexture = tempTexture2; // 두 번째 포탈 렌더링용 텍스처 설정
for (int i = iterations - 1; i >= 0; --i)
{
// 두 번째 포탈을 통해 첫 번째 포탈을 렌더링
RenderCamera(portals[1], portals[0], i, SRC);
}
}
}
private void RenderCamera(Portal inPortal, Portal outPortal, int iterationID, ScriptableRenderContext SRC)
{
// 포탈의 위치와 회전 정보를 얻기 위한 변수들
Transform inTransform = inPortal.transform;
Transform outTransform = outPortal.transform;
Transform cameraTransform = portalCamera.transform;
cameraTransform.position = transform.position; // 카메라의 위치를 현재 객체 위치로 설정
cameraTransform.rotation = transform.rotation; // 카메라의 회전을 현재 객체 회전으로 설정
// 주어진 반복 횟수에 대해 카메라 위치 및 회전을 계산하여 포탈을 통과하도록 설정
for(int i = 0; i <= iterationID; ++i)
{
// 카메라를 다른 포탈 뒤쪽에 배치 (반전된 상대 좌표계 사용)
Vector3 relativePos = inTransform.InverseTransformPoint(cameraTransform.position);
relativePos = Quaternion.Euler(0.0f, 180.0f, 0.0f) * relativePos; // Y축 기준으로 180도 회전
cameraTransform.position = outTransform.TransformPoint(relativePos); // 새로운 위치로 설정
// 카메라가 다른 포탈을 통해 바라보도록 회전 계산
Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * cameraTransform.rotation;
relativeRot = Quaternion.Euler(0.0f, 180.0f, 0.0f) * relativeRot; // Y축 기준으로 180도 회전
cameraTransform.rotation = outTransform.rotation * relativeRot; // 새로운 회전으로 설정
}
// 포탈을 통해 바라볼 때의 기울어진 시야(clip plane) 설정
Plane p = new Plane(-outTransform.forward, outTransform.position); // 포탈 앞면을 기준으로 평면 생성
Vector4 clipPlaneWorldSpace = new Vector4(p.normal.x, p.normal.y, p.normal.z, p.distance); // 월드 공간에서의 clip plane 정보
Vector4 clipPlaneCameraSpace =
Matrix4x4.Transpose(Matrix4x4.Inverse(portalCamera.worldToCameraMatrix)) * clipPlaneWorldSpace; // 카메라 공간으로 변환
// 새로운 프로젝션 매트릭스를 계산하여 카메라에 적용
var newMatrix = mainCamera.CalculateObliqueMatrix(clipPlaneCameraSpace);
portalCamera.projectionMatrix = newMatrix;
// 포탈 카메라 렌더링 수행
UniversalRenderPipeline.RenderSingleCamera(SRC, portalCamera);
}
}
이 PortalCamera 클래스는 포탈 시스템을 구현하기 위한 스크립트로, 두 개의 포탈을 설정하고, 이를 통해 렌더링된 세계를 보여주는 역할을 한다. 이를 통해 "포탈을 통해 다른 공간을 볼 수 있는" 효과를 만든다.
UpdateCamera 메서드

- 포탈이 배치되지 않았거나 (IsPlaced == false) 보이지 않으면(Renderer.isVisible == false) 렌더링하지 않음.
- 보이는 포탈만 portalCamera를 사용해 렌더링.
- iterations 만큼 반복하여 연속적인 포탈 효과 구현.
RenderCamera메서드 포탈 카메라의 위치와 회전을 설정한다

- relativePos: 현재 카메라의 위치를 포탈의 상대 좌표계로 변환 후 180도 회전하여 다른 포탈의 반대쪽으로 이동.
- relativeRot: 포탈 회전을 반영하여 반대쪽 포탈을 통해 본 방향으로 회전.
- Plane p = new Plane(-outTransform.forward, outTransform.position);
- 포탈을 기준으로 보이는 영역을 제한하는 클리핑 평면을 생성.
- CalculateObliqueMatrix()를 사용하여 포탈을 넘어가지 않는 영역을 절단.
- UniversalRenderPipeline.RenderSingleCamera()를 호출하여 포탈 카메라의 장면을 렌더링.
즉 1번 포탈 앞에 플레이어가 있으면 플레이어 기준 거울처럼 반대 되는 위치 값을 가진 포탈 카메라를 2번 포탈 뒤쪽 배치해 2번 포탈에서 보여지는 환경을 1번포탈에 보여준다 플레이어가 움직일때마다 포탈 카메라는 반대로 움직여 마치 포탈 뒤로 다른 공간이 있는 것 같이 보이는 효과
PortalableObject.cs
using System.Collections;
using System.Collections.Generic;
using Unity.VisualScripting;
using UnityEngine;
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
[RequireComponent(typeof(Rigidbody))]
[RequireComponent(typeof(Collider))]
public class PortalableObject : MonoBehaviour
{
// 클론 객체를 저장할 변수. 포탈을 통해 객체를 복사하여 이동하는 데 사용됨.
private GameObject cloneObject;
// 포탈을 통해 이동할 때, 포탈 내부에 있는 객체의 카운트를 추적.
private int inPortalCount = 0;
// 입력 포탈 (객체가 들어가는 포탈)
private Portal inPortal;
// 출력 포탈 (객체가 나오는 포탈)
private Portal outPortal;
// 물리적 특성을 위한 Rigidbody와 Collider 변수.
private new Rigidbody rigidbody;
protected new Collider collider;
public bool IsInPortal = false;
// 포탈 회전 각도를 180도 반전시키는 고정된 Quaternion.
// 포탈의 시점에서 이동할 때 물체의 회전을 반전시킴.
private static readonly Quaternion halfTurn = Quaternion.Euler(0.0f, 180.0f, 0.0f);
// 워프 제한 시간
private float lastWarpTime = -1f;
private float warpCooldown = 0.3f; // 0.4초 동안 다시 워프하지 않도록 제한
// 초기화: 포탈 객체의 복제본 생성 및 설정
protected virtual void Awake()
{
cloneObject = new GameObject();
cloneObject.SetActive(false); // 클론 객체는 기본적으로 비활성화 상태로 생성됨.
var meshFilter = cloneObject.AddComponent<MeshFilter>();
var meshRenderer = cloneObject.AddComponent<MeshRenderer>();
// 클론 객체에 원본 객체의 메쉬와 머티리얼을 복사
meshFilter.mesh = GetComponent<MeshFilter>().mesh;
meshRenderer.materials = GetComponent<MeshRenderer>().materials;
// 클론 객체의 스케일을 원본 객체와 동일하게 설정
cloneObject.transform.localScale = transform.localScale;
// 물리적 특성 초기화
rigidbody = GetComponent<Rigidbody>();
collider = GetComponent<Collider>();
}
// LateUpdate: 포탈을 통해 이동할 때, 클론 객체를 이동시키고 회전
private void LateUpdate()
{
if (inPortal == null || outPortal == null)
{
return; // 포탈이 설정되지 않은 경우 업데이트하지 않음.
}
// 클론 객체가 활성화되어 있고, 두 포탈이 모두 배치되었을 경우
if (cloneObject.activeSelf && inPortal.IsPlaced && outPortal.IsPlaced)
{
var inTransform = inPortal.transform;
var outTransform = outPortal.transform;
// 포탈 내부에 있을 때, 클론 객체의 위치 갱신
Vector3 relativePos = inTransform.InverseTransformPoint(transform.position);
relativePos = halfTurn * relativePos; // 180도 회전 적용
cloneObject.transform.position = outTransform.TransformPoint(relativePos);
// 클론 객체의 회전 갱신
Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * transform.rotation;
relativeRot = halfTurn * relativeRot; // 180도 회전 적용
cloneObject.transform.rotation = outTransform.rotation * relativeRot;
}
else
{
// 클론 객체가 활성화되지 않으면 위치를 비활성화된 위치로 이동시켜서 숨김.
cloneObject.transform.position = new Vector3(-1000.0f, 1000.0f, -1000.0f);
}
}
// 포탈에 들어갔을 때 호출되는 메서드
public void SetIsInPortal(Portal inPortal, Portal outPortal, Collider wallCollider)
{
this.inPortal = inPortal; // 입력 포탈
this.outPortal = outPortal; // 출력 포탈
//IsInPortal = true;
// 포탈 벽과 충돌하지 않도록 처리
Physics.IgnoreCollision(collider, wallCollider);
// 클론 객체 비활성화
cloneObject.SetActive(false);
++inPortalCount; // 포탈에 들어간 객체의 수 증가
}
// 포탈에서 나갔을 때 호출되는 메서드
public void ExitPortal(Collider wallCollider)
{
// 포탈 벽과 충돌 처리를 복원
Physics.IgnoreCollision(collider, wallCollider, false);
//IsInPortal = false;
--inPortalCount; // 포탈을 나간 객체의 수 감소
// 포탈 내부에 남아있는 객체가 없으면 클론 객체를 비활성화
if (inPortalCount == 0)
{
Invoke(nameof(DisableCloneObject), 0.5f);
}
//collider.isTrigger = false;
}
private void DisableCloneObject()
{
cloneObject.SetActive(false);
}
// 포탈을 통한 이동
public virtual void Warp()
{
var inTransform = inPortal.transform;
var outTransform = outPortal.transform;
// 객체의 위치를 포탈을 통해 이동
Vector3 relativePos = inTransform.InverseTransformPoint(transform.position);
relativePos = halfTurn * relativePos; // 180도 회전 적용
transform.position = outTransform.TransformPoint(relativePos);
// 객체의 회전도 포탈을 통해 이동
Quaternion relativeRot = Quaternion.Inverse(inTransform.rotation) * transform.rotation;
relativeRot = halfTurn * relativeRot; // 180도 회전 적용
transform.rotation = outTransform.rotation * relativeRot;
// 물리적 특성(속도)도 포탈을 통해 이동
Vector3 relativeVel = inTransform.InverseTransformDirection(rigidbody.velocity);
relativeVel = halfTurn * relativeVel; // 180도 회전 적용
rigidbody.velocity = outTransform.TransformDirection(relativeVel);
// 포탈 교체
var tmp = inPortal;
inPortal = outPortal;
outPortal = tmp;
}
}
작동 방식 요약
- 물체가 포탈에 들어가면
- cloneObject(클론)를 생성하고 포탈 반대편에 미리 배치함.
- 클론은 물체의 거울 반대 위치에 놓여 있어서, 포탈 반대편의 세계에 있는 것처럼 보임.
- 플레이어가 물체를 보면 포탈을 통해 "다른 공간에 있는 것처럼 보이게 함".
- 물체가 포탈을 통과하면
- 실제 물체를 클론과 동일한 위치로 순간이동 (Warp() 호출).
- 위치, 회전, 속도를 반대편 포탈 기준으로 변환해서 자연스럽게 이동.
- 포탈을 통과한 후, inPortal과 outPortal을 바꿔서 다음 이동을 준비.
- 클론(가짜 객체)의 역할
- 플레이어가 포탈을 통과하기 전까지 반대편에 보이게 해줌.
- 통과한 후에는 cloneObject를 숨김.
Warp() 메서드

포탈기준 상대 위치를 구해서 반대편으로 이동
- InverseTransformPoint() → 현재 물체의 입력 포탈 기준 상대 위치를 구함
- halfTurn * relativePos → 180도 회전 적용
- TransformPoint() → 반대쪽 포탈 기준으로 변환해서 위치 이동
- Inverse(inTransform.rotation) * transform.rotation → 입력 포탈을 기준으로 상대적인 회전 구함
- halfTurn * relativeRot → 180도 반전
- outTransform.rotation * relativeRot → 반대쪽 포탈에서 동일한 각도로 배치
-
- InverseTransformDirection() → 속도를 포탈 기준 상대적인 값으로 변환
- halfTurn * relativeVel → 속도 방향도 180도 반전
- TransformDirection() → 반대쪽 포탈에서 적용
포탈 1에 들어 갈 때 포탈 2에 플레이어 클론을 생성하고 포탈에 들어가면 그 위치로 이동 하지만 콜라이더 때문에 포탈 공간으로 들어가는 느낌이 아니라 그냥 순간이동 처럼 보일 수 있음 해결책은 SetInPortal메서드에 있음

콜라이더 충돌 제거
Physics.IgnoreCollision(collider, wallCollider);
→ 플레이어가 포탈이 달려있는 벽과 부딪히지 않도록 설정
'Unity숙련주차' 카테고리의 다른 글
유니티 개발 숙련주차4. 아이템 상호작용 (0) | 2025.03.06 |
---|---|
유니티 개발 숙련주차 3. 조명기능 만져서 낮과 밤 구현 (0) | 2025.03.05 |
유니티 개발 숙련 2. 플레이어에게 데미지 주기 (0) | 2025.03.05 |
유니티 게임개발 숙련 1. 플레이어 만들기 (0) | 2025.03.04 |