1. 플레이어 선택 블럭 보여주기 (외곽선)
2. 플레이어 선택 블럭의 면 강조하기
3. 마우스 올렸을 때, 선택 칸 보여주기
플레이어 선택 블럭 보여주기(외곽선)
큐브를 클릭했을 때, 현재 클릭한 큐브를 강조하기 위해 외곽선을 띄우는 방식으로 만들 것이다.
외곽선에 대해 찾아본 결과 Shader를 따로 만드는게 좋은거 같아서 배우면서 만들어 봤다.
먼저 셰이더 그래프를 하나 만들어 준다.

외곽선의 색인 Color와 외곽선의 크기를 변수로 사용하기 위해 Outline Color와 OutLine Scale을 변수로 만들어 준다.

Position * Outline Scale로 외곽선 만들기

빈 공간에 우클릭 혹은 스페이스바를 통해 Position과 Multiply를 가져오고 Scale을 드래그 해서 Position과 Scale을 곱해준다.
또한 Position의 Space를 Object로 함으로써 오브젝트와 동일하게 외곽선이 움직이도록 한다.
이 작업은 이 셰이더를 붙일 오브젝트의 크기에 Scale에 곱한 값을 사용하기 때문이다.
외곽선 위치 정하기, 색 정하기

출력된 외곽선 크기값을 Vertex의 Position에 넣어 외곽선의 위치및 크기를 잡는다.
Outline Color를 Fragment의 Base Color에 드래그 하여 외곽선의 색을 정한다.
Graph Settings

이 작업이 되게 중요한데, Surface Type을 Transparent로 투명하게끔 설정한뒤 Render Face를 Back으로 설정한다.
기본적으로 외곽선을 만드는 기법인 Inverted hull의 경우 기본 오브젝트보다 더 큰 껍데기를 만들고, 이 껍데기를 투명하게 만들어서 오브젝트 뒤에 외곽선(껍데기)이 보이게끔 한다.
Transparent의 경우 Opaque(불투명)물체 뒤에 그려지고, Render Face의 Back은 카메라를 마주보는 앞면을 그리지 않기 때문에, 가운데 오브젝트가 보여야 하는 곳은 뚫리게 보여진다.
이렇게 만든 Shader를 사용하는 material을 만들고 값을 넣어서 mesh Renderer에 추가하면 외곽선이 있는 오브젝트를 만들 수 있다.


외곽선 코드 작성하기
InputController
[SerializeField] CubeShader cubeShader;
void LeftClick() // 블럭을 좌클릭 하면 생성할 블럭의 종류 UI를 표시한다.
{
if(Input.GetMouseButtonDown(0) && !isSeleting)
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 카메라로 부터 클릭한 월드좌표까지 광선
if (Physics.Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask("Cube"))) // 광선에 맞은 충돌체를 담는다.
{
if (hit.transform.GetComponent<Block>().myCaster == this)
{
UIManager.Instance.ShowSeleteBlock(Input.mousePosition); // 큐브 선택창 생성
isSeleting = true;
// cubeCreator.InsCube(hit.normal, hit.transform, this); // 큐브 생성하기
}
camera_M.ChengeTarget();
cubeShader.CubeOutLine(hit.transform.gameObject);
}
}
}
외곽선을 생성하는 함수의 호출은 이곳에서 관리한다.
CubeShader
using NUnit.Framework;
using System.Collections.Generic;
using UnityEngine;
public class CubeShader : MonoBehaviour
{
GameObject pastCube;
[SerializeField] Material outLine;
public void CubeOutLine(GameObject cube)
{
if(pastCube != cube)
{
if (pastCube != null)
{
MeshRenderer pastMesh = pastCube.GetComponent<MeshRenderer>();
Material[] pastMat = new Material[pastMesh.materials.Length - 1]; // 현재 배열보다 1더 작은 배열 생성
for(int i = 0; i < pastMat.Length; i++)
{
pastMat[i] = pastMesh.materials[i];
}
pastMesh.materials = pastMat; // 외곽선 제외한 머티리얼 넣기
}
MeshRenderer mesh = cube.GetComponent<MeshRenderer>();
Material[] mat = new Material[mesh.materials.Length + 1]; // 현재 배열보다 1더 큰 배열 생성
for (int i = 0; i < mesh.materials.Length; i++)
{
mat[i] = mesh.materials[i];
}
mat[mesh.materials.Length] = outLine; // 마지막 배열칸에 외곽선 넣기
mesh.materials = mat;
pastCube = cube; // 외곽선이 있는 큐브 저장
Debug.Log("외곽선 생성");
}
}
}
CubeShader라는 새 스크립트를 만들어서 코드를 작성한다.
CubeShader는 선택한 큐브의 외곽선 추가 및 삭제를 담당한다.
먼저 선택한 큐브와 함께 CubeOutLine함수가 호출되면 그 큐브가 외곽선이 있는지 판단해서 없으면 외곽선이 있는 큐브의 외곽선을 제거하고, 선택한 새 큐브에 외곽선을 넣는다. 외곽선이 존재하지 않으면 외곽선 추가 코드만 실행된다.
전체적인 제거와 추가의 구조는 새로운 Material 배열을 만들어서 이곳에 기존에 있던 Material을 넣고, 제거하려면 배열의 크기를 1 줄이고, 추가하려면 배열의 크기를 1 늘려서 존재하는 기존 Material을 담고, 추가 변형이후에 새로 만든 배열을 MeshRenderer의 Material로 전환한다.
Materials.Length == 1 이런식으로 사용하지 않은 이유는 확장성을 위해 많은 Material을 가진 오브젝트가 있을수 있으니 이렇게 고안해서 작성해 보았다.

완성본

플레이어 선택 블럭의 면 강조하기
선택의 면의 경우 플레이어가 자신의 큐브를 클릭했을 때, 설치될 면을 강조시키고, 페이드인,아웃으로 깜빡임을 처리할 것이다.

InputController
[SerializeField] SeletePlane seletePlane;
void LeftClick() // 블럭을 좌클릭 하면 생성할 블럭의 종류 UI를 표시한다.
{
if(Input.GetMouseButtonDown(0) && !isSeleting)
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); // 카메라로 부터 클릭한 월드좌표까지 광선
if (Physics.Raycast(ray, out hit, Mathf.Infinity, LayerMask.GetMask("Cube"))) // 광선에 맞은 충돌체를 담는다.
{
if (hit.transform.GetComponent<Block>().myCaster == this)
{
UIManager.Instance.ShowSeleteBlock(Input.mousePosition); // 큐브 선택창 생성
isSeleting = true;
seletePlane.InsPlane(hit);
// cubeCreator.InsCube(hit.normal, hit.transform, this); // 큐브 생성하기
}
camera_M.ChengeTarget();
cubeShader.CubeOutLine(hit.transform.gameObject);
}
}
}
myCaster가 자신일 때, 즉 자신의 큐브를 클릭했을 때만 InsPlane함수를 호출한다.
SeletePlane
using System.Collections;
using UnityEngine;
public class SeletePlane : MonoBehaviour
{
[SerializeField] GameObject seletePlane;
public void InsPlane(RaycastHit hit) // 선택한 면 강조하는 오브젝트 생성
{
Vector3 InsPosition = hit.transform.position + hit.normal * 0.5001f ;
float addRotation = 0;
switch(hit.normal.z)
{
case 1: addRotation = 180; break;
default: break;
}
Quaternion InsRotation = Quaternion.Euler(hit.normal.y * 90, hit.normal.x * (-90) + addRotation,0);
Instantiate(seletePlane, InsPosition, InsRotation);
print(hit.normal);
}
}
SeletePlane이라는 새 스크립트를 생성한다.
SeletePlane은 선택된 면을 강조하는데 사용할 것이다.
InsPlane 함수는 충돌체의 정보를 받아서 알맞은 면에 seletePlane이라는 오브젝트를 생성시킨다.
생성위치와 회전은 CubeCreator의 코드와 유사하지만 생성위치에 0.5가 아닌 0.5001을 곱한 이유는 같은 좌표를 가지고 있을 때, Z-Fighting과 같은 현상을 방지 하기 위해, 기존 블럭보다 아주 조금 앞에 나와있도록 해주었다.
seletePlane

오브젝트는 Quad를 사용하며, Material의 경우 강조도 해야하고, 투명도도 사용해야 하기 때문에 Shader를 URP-Unlit으로 만들었다. (Transparent)
FadeIn/Out 만들기
[SerializeField] float blinkTime = 0.5f;
IEnumerator FadePlane(GameObject plane) // 선택 면의 페이드 인, 아웃
{
while(true)
{
Material mat = plane.GetComponent<MeshRenderer>().material;
Color color = mat.color;
float start = color.a;
float t = 0f;
while (t <= blinkTime)
{
color.a = Mathf.Lerp(start, 1, t / blinkTime);
t += Time.deltaTime;
mat.color = color;
yield return null;
}
Debug.Log("알파값 1");
t = 0;
start = color.a;
while (t <= blinkTime)
{
color.a = Mathf.Lerp(start, 0, t / blinkTime);
t += Time.deltaTime;
mat.color = color;
yield return null;
}
Debug.Log("알파값 0");
yield return null;
}
}
페이드 인 아웃을 담당하는 코루틴 함수 FadePlane은 파라미터로 블링크 효과를 줄 plane을 받고, StopCoroutine을 하지 않는 동안 계속해서 알파값을 변경하며, 강조효과를 준다.
기본적으로 현재 Material의 알파값을 받고, Lerp를 사용하여 현재 알파값에서 1까지, 혹은 0까지 정해진 시간동안 일정하고 부드럽게 알파값이 변경되도록 한다.
[SerializeField] GameObject seletePlane;
private GameObject activeObject; // 현재 생성되어있는 선택한 면 오브젝트
private Coroutine myCoroutine;
public void DeletePlane()
{
if(activeObject != null)
{
Destroy(activeObject);
}
if(myCoroutine != null)
{
StopCoroutine(myCoroutine);
}
}
public void InsPlane(RaycastHit hit) // 선택한 면 강조하는 오브젝트 생성
{
Vector3 InsPosition = hit.transform.position + hit.normal * 0.5001f ;
float addRotation = 0;
switch(hit.normal.z)
{
case 1: addRotation = 180; break;
default: break;
}
Quaternion InsRotation = Quaternion.Euler(hit.normal.y * 90, hit.normal.x * (-90) + addRotation,0);
activeObject = Instantiate(seletePlane, InsPosition, InsRotation);
if(myCoroutine != null)
{
StopCoroutine(myCoroutine);
}
myCoroutine = StartCoroutine(FadePlane(activeObject));
}
activeObject의 경우 현재 실행되고 있는 면을 담고 삭제할때 사용한다.
InsPlane에서 현재 myCoroutine 즉 페이드인 아웃 효과를 하고있는 면이 있으면 중단하고, 새로운 면으로 페이드인아웃을 실행한다.
DeletePlane의 경우, 블럭 선택창에서 카메라를 움직이거나, 블럭을 생성했을 때, 즉 블럭 선택 UI창이 사라질때 동시에 강조 면도 사라지게 하기 위한 함수이다.
InputController
public void seleteBlock(int num)
{
isSeleting = false;
UIManager.Instance.InActiveSeleteBlock();
switch(num)
{
case 1:
Debug.Log(hit.normal + " " + hit.transform);
cubeCreator.InsCube(hit.normal, hit.transform, this); // 큐브 생성하기
break;
case 2:
cubeCreator.Conversion(hit.normal, hit.transform, this); // 큐브 생성하기
break;
case 3:
cubeCreator.CreateCannon(hit.normal, hit.transform, this); // 큐브 생성하기
break;
default: break;
}
seletePlane.DeletePlane();
}
public void CancelSelete()
{
isSeleting = false;
seletePlane.DeletePlane();
}
seleteBlock과 같이 블럭을 생성했을 때와 CancelSelete처럼 블럭 생성을 취소하여 생성UI가 사라진 경우 DeletePlane을 호출하여, 강조된 면 오브젝트를 지운다.
완성본

마우스 올렸을 때, 선택 칸 보여주기
마우스를 큐브에 올렸을 때, 노란색 외곽선을 블럭에 띄어 현재 마우스포인터가 가리키고 있는 블럭을 보여주는 기능을 만들 것이다.
전체적으로 아까 만들었던 외곽선과 매우 비슷하게 만들어 볼수 있을 것 같다.

아까 만들었던 외곽선 셰이더와 동일하게 셰이더 그래프를 하나 만들어 준다.
Material배치 알고리즘 만들기

이 그림처럼 외곽선이 2개이상일 경우 밑에있는 요소가 나중에 렌더링되기 때문에, 마우스를 올렸을 때 생성되는 외곽선(노락색)이 선택 외곽선(주황색)보다 앞에 있어야한다. (오른쪽 그림)
InputController
void Update()
{
if(GameManager.Instance.myState == GameManager.GameState.START)
{
LeftClick();
OnMouseRay();
}
Interrupt();
}
private void OnMouseRay() // 마우스가 현재 가리키는 오브젝트를 계속 검사한다.
{
RaycastHit hit;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if(Physics.Raycast(ray,out hit,Mathf.Infinity,LayerMask.GetMask("Cube")))
{
cubeShader.MouseOutLine(hit.transform.gameObject);
}
else
{
cubeShader.MouseExitOutLine();
}
}
먼저 입력을 받기 위한 함수를 만들어준다.
기본적으로 마우스가 큐브위에 올라가있을 때를 계속 판단해야 하기 때문에 계속해서 마우스포지션에 따른 Ray를 월드스페이스에 발사해서, 큐브와 닿는지, 닿지않는지를 판단한다.
MouseOutLine, 마우스를 올렸을 때, 외곽선을 표시하는 함수에는 해당 큐브의 정보를 argument로 넘기고,
MouseExitOutLine, 마우스를 올리지 않았을 경우 외곽선을 제거하는 함수를 호출한다.
CubeShader - 기존 변경점
GameObject seletePastCube;
[SerializeField] Material seleteOutLine; // 큐브를 클릭했을 때 생성되는 외곽선
public void CubeOutLine(GameObject cube)
{
if(seletePastCube != cube)
{
if (seletePastCube != null)
{
MeshRenderer pastMesh = seletePastCube.GetComponent<MeshRenderer>();
Material[] pastMat = new Material[pastMesh.materials.Length - 1]; // 현재 배열보다 1더 작은 배열 생성
for(int i = 0; i < pastMat.Length; i++)
{
pastMat[i] = pastMesh.materials[i];
}
pastMesh.materials = pastMat; // 외곽선 제외한 머티리얼 넣기
}
MeshRenderer mesh = cube.GetComponent<MeshRenderer>();
Material[] mat = new Material[mesh.materials.Length + 1]; // 현재 배열보다 1더 큰 배열 생성
for (int i = 0; i < mesh.materials.Length; i++)
{
mat[i] = mesh.materials[i];
}
mat[mesh.materials.Length] = seleteOutLine; // 마지막 배열칸에 외곽선 넣기
mesh.materials = mat;
seletePastCube = cube; // 외곽선이 있는 큐브 저장
Debug.Log("외곽선 생성");
}
}
비슷한 쓰임새의 변수를 사용하기 위해 변수명을 바꿨다. 그외의 변경점X
CubeShader - 추가
GameObject onMousePastCube;
[SerializeField] Material onMouseOutLine; // 마우스를 올렸을 때 생성되는 외곽선
public void MouseOutLine(GameObject cube)
{
if(onMousePastCube != cube)
{
MouseExitOutLine();
MeshRenderer mesh = cube.GetComponent<MeshRenderer>();
Material[] mat = new Material[mesh.materials.Length + 1]; // 현재 배열보다 1더 큰 배열 생성
int num = -1;
for (int i = 0; i < mesh.materials.Length; i++) // 기존 Material 넣기
{
mat[i] = mesh.materials[i];
if (mesh.materials[i].shader.name == "Shader Graphs/Outline_Shader")
{
num = i;
}
}
if(num < 0) // 주황색 외곽선 Material이 없다.
{
mat[mesh.materials.Length] = onMouseOutLine; // 마지막 배열칸에 마우스외곽선 넣기
}
else
{
mat[mat.Length - 1] = mesh.materials[num]; // 마지막 배열칸에 외곽선 넣기
mat[num] = onMouseOutLine;
}
mesh.materials = mat;
onMousePastCube = cube;
Debug.Log("선택전 외곽선 생성");
}
}
public void MouseExitOutLine() // 마우스가 올려서 생긴 외곽선 오브젝트에서 벗어났을 때,
{
if(onMousePastCube != null) // OnMouse 오브젝트가 있엇다면
{
MeshRenderer mesh = onMousePastCube.GetComponent<MeshRenderer>();
int num = -1;
for(int i = 0; i < mesh.materials.Length; i++)
{
if (mesh.materials[i].shader.name == "Shader Graphs/MouseOnOutline_Shader") // OnMouse 외곽선 셰이더라면
{
num = i;
mesh.materials[i] = null;
Debug.Log(mesh.materials[i].name);
break;
}
}
Debug.Log("num : " + num);
Material[] mat = new Material[mesh.materials.Length - 1];
Debug.Log(mat.Length);
for(int i = 0; i < num; i++) // OnMOuse 외곽선 셰이더 앞의 Mateiral 옮기기
{
mat[i] = mesh.materials[i];
}
for (int i = num + 1; i < mesh.materials.Length; i++) // OnMouse 외곽선 셰이더 뒤의 Mateiral 한칸식 앞으로
{
mat[i - 1] = mesh.materials[i];
}
mesh.materials = mat;
onMousePastCube = null;
}
}
먼저 MouseOutLine함수를 살펴보자면 이 함수는 전체적으로 마우스를 큐브위에 올렸을 때, 외곽선을 띄게 하는 기능을 한다.
처음엔 기존 외곽선을 띄고있는 오브젝트를 계속 호출하는 것인지를 판단한다. 즉 한 오브젝트 위에 마우스를 올려두면 함수가 추가로 실행되지 않는다.
이후 MouseExitOutLine이라는 함수를 호출하여서 큐브와 큐브의 이동에서 빈 공간 없이 즉시 이어질 경우를 방지해서 새롭게 외곽선을 만든다.
그 다음 배열의 크기를 기존 크기보다 1크게 만들어서 기존 material을 넣는데, 이때, material의 shader 이름으로 검사하여, 선택 외곽선(주황색)이 존재할 경우, 마우스 외곽선(노란색)을 그 위치에 두고, 선택 외곽선을 배열 마지막에 두는 알고리즘을 사용한다.
public void MouseExitOutLine() // 마우스가 올려서 생긴 외곽선 오브젝트에서 벗어났을 때,
{
if(onMousePastCube != null) // OnMouse 오브젝트가 있엇다면
{
MeshRenderer mesh = onMousePastCube.GetComponent<MeshRenderer>();
int num = -1;
for(int i = 0; i < mesh.materials.Length; i++)
{
if (mesh.materials[i].shader.name == "Shader Graphs/MouseOnOutline_Shader") // OnMouse 외곽선 셰이더라면
{
num = i;
mesh.materials[i] = null;
Debug.Log(mesh.materials[i].name);
break;
}
}
Debug.Log("num : " + num);
Material[] mat = new Material[mesh.materials.Length - 1];
Debug.Log(mat.Length);
for(int i = 0; i < num; i++) // OnMOuse 외곽선 셰이더 앞의 Mateiral 옮기기
{
mat[i] = mesh.materials[i];
}
for (int i = num + 1; i < mesh.materials.Length; i++) // OnMouse 외곽선 셰이더 뒤의 Mateiral 한칸식 앞으로
{
mat[i - 1] = mesh.materials[i];
}
mesh.materials = mat;
onMousePastCube = null;
}
}
MouseExitOutLine 함수는 마우스가 외곽선을 그리지 않고 오브젝트를 벗어났을 때, 호출된다. 아까 말한것처럼 onMousePastCube 변수를 사용해서, 기존 오브젝트에서의 마우스 외곽선을 지운다.
함수를 살펴보면, 현재 노란색 외곽선을 처음 생성하게 되면 함수가 실행되지 않는다.
먼저 전에 있었던 오브젝트의 Material에서 노란색 셰이더가 존재하면 해당 material을 지운다. (첫번째 for문)
이후 mat으로 material 배열을 만들어서 새로 만들기 시작하는데, 방금 지운 material을 num이라는 변수를 사용해 배열을 만든다.
num의 앞에 있는 Material들을 동일하게 같은 인덱스로 복사한다. (두번째 for문)
num 뒤에 있는 Material들은 모두 한 인덱스값씩 앞으로 오게 되여 지워진 material자리를 채운다. (세번째 for문)
mat의 경우 처음 생성할 때, 기존 배열보다 1작은 크기를 가지고 있기 때문에 이렇게 사용해도 null을 가진 Material 공간이 없다.
이후 mat을 기존 노란 외곽선이 있었던 오브젝트에 넣어주고, onMOusePastCube를 null로 하여, 이미 처리를 끝낸 오브젝트를 건들지 않는다.
완성본

Shader도 처음 사용해 봤고, 알고리즘도 오랜만에 힘들게 작성하고 완성해서 뿌듯하다.
'GameDev > Cubidom' 카테고리의 다른 글
| Cubidom 카메라 버그, 맵 배경, 선택 UI 수정 (0) | 2026.04.05 |
|---|---|
| Cubidom 시인성 향상3 (0) | 2026.04.04 |
| Cubidom 시인성 향상1 (0) | 2026.03.31 |
| Cubidom Option창 만들기 - 슬라이더로 카메라 민감도 조절 (0) | 2026.03.30 |
| Cubidom 일시 정지 만들기 (0) | 2026.03.29 |