1. 버튼에 넣을 이미지 구하기

어제 위 이미지와 같이 UI를 구현하였고
이제 각 버튼별로 의상 이미지를 넣어주려고 한다.

거의 첫 시작인데 여기서부터 문제가 생겼다.
해당 에셋은 여러 의상이 프리팹으로 만들어져 있지만
해당 프리팹에 대한 프리뷰 이미지 즉 png나 jpeg같이 스프라이트 이미지가 제공되지 않았다는 것이다.
리소스 파일을 아무리 뒤져봐도 메쉬만있지 진짜 이미지가 하나도 안 보였다..
흠.. 조졌네
이러고 있었죠
근데 해내야죠 뭐 어쩌겠어 그쵸?

지피티가 알려준 방안은 우선 이렇다.
ㅇㅋ 말은 쉽지 이걸 어떻게 구현하냐.. 휴우~ 오전 시간에는 이걸로 내내 끙끙 앓았던 것 같다.
1. AssetDatabase 클래스 활용
AssetDatabase - Unity 매뉴얼
AssetDatabase는 프로젝트에 포함된 에셋에 접근하게 해주는 API입니다. 특히 에셋을 찾고 로드하며 생성, 삭제, 수정하는 메서드를 제공합니다. Unity 에디터는 내부적으로 AssetDatabase를 사용해 에셋
docs.unity3d.com
AssetDatabase는 프로젝트에 포함된 에셋에 접근하게 해주는 API이다.
특히 에셋을 찾고 로드하며 생성, 삭제, 수정하는 메서드를 제공한다.
이 클래스를 활용하면
에셋(프리팹)을 찾고 씬에 생성, 캡쳐, 삭제 작업을 할 수 있다.
foreach (var guid in Selection.assetGUIDs)
Selection.assetGUIDs는 프로젝트 창에서 선택된 에셋들의 GUID을 뜻한다.
GUID는 유니티가 에셋을 추적하는 내부 식별자이다.
파일의 이름이나 위치가 변경되어도 고유 ID값은 변환되지 않기 때문에 안정적으로 에셋을 찾을 수 있다.

유니티 다큐먼트를 확인해보면 유니티는 Meta 데이터를 유지하기 때문에
에셋 파일을 다루기 위해서는 AssetDatabase 클래스에 정의된 메서드들을 활용해야 한다는 것 같다.
나는 지금 씬에 프리팹을 로드해서 이미지를 따오는 로직을 구현해야 하니까
우선 유니티에게 에셋 파일이 어디에 있는지 알려줘야한다.
string path = AssetDatabase.GUIDToAssetPath(guid);
그래서 Selection.assetGUIDs로 얻은 GUID를 통해서 해당 에셋의 경로를 확보해주었다.
이렇게 얻은 경로는
AssetDatabase.LoadAssetAtPath<GameObject>(path);
에 넣으면 실제 프리팹 오브젝트를 로드할 수 있고 이제 이걸 씬에 인스턴스화해서 카메라로 찍을 수 있다.
- 지정한 경로의 에셋 파일을 읽어서 UnityEngine.Object 타입으로 반환
- 제네릭 <T> : 어떤 타입의 에셋으로 읽어올지 지정
- GameObject → 프리팹, 씬에 올릴 수 있는 오브젝트
- Texture2D → 텍스처 파일
- AudioClip → 오디오 파일
var root = new GameObject("~IconBakeRoot")
{
hideFlags = HideFlags.HideAndDontSave
};
씬에 IconBakeRoot라는 오브젝트를 만들어준다.
이때 내가 원하는 건 아이콘만 후다닥 찍고 이 오브젝트는 삭제해줄 거니까
하이어라키에 표시하지말고, 씬 파일에 저장하지 않도록 해주었다.
var inst = (GameObject)PrefabUtility.InstantiatePrefab(prefab, root.transform);
- PrefabUtility.InstantiatePrefab : 에디터 전용 API로 프리팹을 씬에 인스턴스화한다.
- 일반적인 Object.Instantiate와 달리 프리팹 연결 정보를 유지하는 등 에디터 친화적이라고 한다..!
- 두 번째 인자로 root.transform을 줬으므로 생성된 인스턴스는 ~IconBakeRoot 오브젝트의 자식으로 들어간다.
- 반환값을 GameObject로 캐스팅해서 inst 변수에 저장한다.
| PrefabUtility.InstantiatePrefab | Object.Instantiate | |
| 네임스페이스 | UnityEditor (에디터 전용) | UnityEngine (런타임/에디터) |
| 주 용도 | 프리팹 에셋을 “씬에 새 인스턴스”로 생성. **프리팹 연결(Overrides/Apply/Revert)**을 올바르게 유지하고, Prefab Stage/현재 씬 같은 에디터 컨텍스트를 정확히 반영 | 임의의 UnityEngine.Object를 복제(클론). 씬 오브젝트도, 프리팹 인스턴스도, 프리팹 에셋 자체도 복제 가능 |
| 프리팹 연결 | 보장: 결과가 “프리팹 인스턴스”로서 원본 에셋과 연결되어 오버라이드 추적이 정상 동작 | 일반적인 복제. 연결이 생길 때도 있지만(버전/상황에 따라) 에디터 컨텍스트 인식이 부족. 프리팹 모드/스테이지 인식 X |
| 어디서 쓰나 | 툴/에디터 스크립트에서 안정적인 프리팹 배치가 필요할 때(지금 케이스) | 런타임에 적, 총알, 이펙트 등을 찍어내거나, 씬 오브젝트/컴포넌트를 단순 복제할 때 |
| 부모/씬 배치 | 오버로드로 부모 Transform이나 특정 씬/스테이지에 정확히 배치 | 부모 주면 그 밑으로 들어가지만, 프리팹 스테이지 같은 에디터 문맥은 모름 |
| Undo 등 에디터 통합 | 에디터 워크플로우에 친화적(필요하면 Undo.RegisterCreatedObjectUndo 같이 사용) | 기본 복제. 에디터 워크플로우 인식은 제한적 |
❓ (GameObject)로 캐스팅하는 이유
PrefabUtility.InstantiatePrefab(...)의 반환형은 UnityEngine.Object이다.
유니티 에디터는 광범위한 Object 타입을 다루기 때문에 어느 오브젝트인지 지정해주어야 한다.
[오브젝트 삭제]
| Object.Destroy(obj) | Object.DestroyImmediate(obj) | |
| 실행 시점 | 지연 삭제: 이번 프레임이 끝날 때 파괴 | 즉시 삭제: 호출 순간 바로 파괴 |
| 사용 맥락 | **런타임(Play Mode)**에서 일반적으로 사용 | 에디터 코드 / Edit Mode에서 임시 오브젝트 정리할 때 주로 사용 |
| 안전성 | 프레임 끝에 파괴되므로, 그 프레임 동안 참조 중인 코드와 충돌 위험 적음 | 즉시 없애므로, 그 다음 줄부터는 절대 접근하면 안 됨. 순서·참조 주의 |
| 에디터에서 | Edit Mode에서 호출하면 경고/제한이 있음 → 보통 권장 X | Edit Mode 정리용으로 권장 (메뉴 커맨드, 임시 생성물 정리 등) |
| 에셋 삭제 | 불가 | DestroyImmediate(obj, true)로 에셋까지 즉시 삭제 가능(위험). 보통은 AssetDatabase.DeleteAsset 사용 권장 |
아이콘을 찍고난 후에는 바로 오브젝트들을 바로 그자리에서 삭제해주어야 한다.
다음 프리팹을 처리할 때 이전 작업이 남아있으면 원하는 바운더리가 안 나타날 수 있기 때문이다.
[바운더리 - AABB]
Bounds b = renderers[0].bounds;
foreach (var r in renderers)
{
b.Encapsulate(r.bounds);
}
AABB = Axis-Aligned Bounding Box의 약자로 “축( X/Y/Z )에 정렬된 경계 박스”를 의미한다.
Renderer.bounds가 월드 공간 AABB를 돌려주고
여러 Renderer의 AABB를 Bounds.Encapsulate로 한데 모아 프리팹 전체 AABB를 만들어주었다.
cam.cullingMask = 1 << iconLayer;
레이어 마스크(cullingMask)는 32비트 비트 마스크로
해당 레이어 인덱스의 비트가 1이면 보이고 0이면 보이지 않는다
그래서 30번째 레이어만 보이게 하기 위해서 1 << 30으로 설정


float radius = b.extents.magnitude;
float dist = radius / Mathf.Sin(Mathf.Deg2Rad * cam.fieldOfView * 0.5f);
- b.extents는 바운즈의 반쪽 크기(half-size) 벡터 → (ex, ey, ez) = (size.x/2, size.y/2, size.z/2)
- .magnitude는 그 벡터의 길이
바운즈가 여러 렌더러를 다 감싸서 사각형 형태로 만들어졌는데 이걸 구로 감싸서 좀 떨어져서 촬영되도록


[RenderTexture / Texture2D]
var rt = new RenderTexture(ICON_SIZE, ICON_SIZE, 24, RenderTextureFormat.ARGB32);
형식: RenderTexture(int width, int height, int depthBufferBits, RenderTextureFormat format)
depthBufferBits는 깊이 버퍼로 가까운 게 멀리있는 것을 가린다 (픽셀 지글거리는거 완화) => 정밀도, 단순하게 픽셀 퀄리티 높이는 거라고 생각하면 될 것 같다
format은 알파, 빨강, 초록, 파랑 4개의 색상 포맷을 갖고 각 8비트로 32비트 사용
var tex = new Texture2D(ICON_SIZE, ICON_SIZE, TextureFormat.RGBA32, false, false);
형식: Texture2D(int width, int height, TextureFormat format, bool mipChain, bool linear)
mipChain = false: 아이콘/스프라이트는 보통 한 크기로 쓰니 밉맵이 필요 없고 꺼두면 더 또렷하고 가볍다.
linear = false: sRGB(감마) 텍스처로 취급. 일반 UI/스프라이트 용도에 자연스러운 선택
Mipmap은 3D 텍스처에서 거리에 따라 자동 축소된 버전을 쓰는 기능
void ReadPixels(Rect source, int destX, int destY, bool recalcMipMaps = true)
tex.ReadPixels(new Rect(0, 0, ICON_SIZE, ICON_SIZE), 0, 0);
형식 : ReadPixels(어디서 읽을지, (어디에 붙여넣기 시작할지)x좌표, ('')y좌표, 밉맵 계산)
사용전에 RenderTexture.active 로 읽을 대상을 활성화 시켜야함. 안하면 못읽음!
복사를 적용하려면 Apply() 호출해야 실제 픽셀 데이터가 텍스처에 반영됨
AssetDatabase.ImportAsset(savePath); // 경로 새로고침 -> 유니티가 에셋 찾을 수 있도록 알려주는 기능
var ti = (TextureImporter)AssetImporter.GetAtPath(savePath);
ti.textureType = TextureImporterType.Sprite;
ti.spriteImportMode = SpriteImportMode.Single;
ti.alphaIsTransparency = true;
ti.mipmapEnabled = false;
ti.SaveAndReimport();
AssetImporter.GetAtPath(savePath)
- 에셋으로 등록된 PNG는 “에셋 임포터(Importer)” 객체를 통해 관리됨.
- 유니티는 파일마다 다른 Importer를 쓴다.
- .fbx → ModelImporter
- .wav → AudioImporter
- .png/.jpg → TextureImporter
- 그래서 여기서는 TextureImporter를 사용


이렇게 Tool 에디터를 통해서 프리팹의 이미지를 따오는 것을 구현 완료하였다.
2. 옷 입히기 로직

이제 UI에 구현한 옷 버튼을 클릭하면
왼쪽에 있는 프리뷰 모델에 옷이 입혀져야 한다.
이걸 버튼마다 On Click을 구현하면 노답이다
옷이 거의 30개가되는데 30개에 다 On click 을 넣을 생각을하니 벌써부터 빡빡하고 나중에 유지보수할 때 최악일 거란 생각이 들었다.
어제 멘토님도 딕셔너리를 활용하라고 조언해주셨으니 한번 딕셔너리를 활용해보겠다.
1. 딕셔너리란?
키(Key)와 값(Value)를 짝으로 저장하는 자료구조로
C#에서는 Dictionary<TKey, TValue>로 선언한다.
Dictionary<string, int> ages = new Dictionary<string, int>();
ages["철수"] = 15;
ages["영희"] = 14;
Debug.Log(ages["철수"]); // 15 출력
캐릭터가 신발을 하나 착용했는데 그 위에 또 다른 신발이 착용되면 2개의 신발을 착용한 것 처럼 나타나니까
이런것도 딕셔너리로 관리할 수 있다.
public enum SlotType
{
FullBody,
FaceAccessory,
Glasses,
Gloves,
Hair,
Hat,
Top,
Bottom,
Shoes,
Accessory,
}
static readonly Dictionary<string, SlotType> FolderToSlot = new()
{
{ "Costumes", SlotType.FullBody },
{ "Face Accessories", SlotType.FaceAccessory },
{ "Glasses", SlotType.Glasses },
{ "Gloves", SlotType.Gloves },
{ "Hairstyle", SlotType.Hair },
{ "Hairstyle Single", SlotType.Hair },
{ "Hat", SlotType.Hat },
{ "Hat Single", SlotType.Hat },
{ "Outwear", SlotType.Top },
{ "Pants", SlotType.Bottom },
{ "Shoes", SlotType.Shoes },
{ "Shorts", SlotType.Bottom },
{ "Socks", SlotType.Shoes },
// 제외: Body / Faces / Mascots(이건 풀바디에 합칠지 고려중..)
};

딕셔너리를 이렇게 만들어준 이유는
프리팹 에셋이 저렇게 경로안에 다 들어가있어서..
폴더명 → 어떤 부위의 옷인지
즉 Glasses폴더에서 아이템을 불러오면 SlotTpye.Glasses 슬롯에 들어가도록 해주었다.
Resources.LoadAll<GameObject>(folderName);
Resources.LoadAll 메서드는 유니티 에디터의 Assets/Resources/ 경로에 있는 것들만 로드하기 때문에
프리팹들이 해당 경로에 저장되어 있어야 읽어들일 수 있다.
그래서 위 이미지의 경로는 사용 못하고! Resources에 에셋들을 저장해주었다.
foreach (var go in prefabs) // 불러온 프리팹들에 ItemInfo 값 적용
{
if (!go) continue;
string id = go.name;
byId[id] = new ItemInfo
{
id = id,
slot = slot,
prefab = go
};
}
이제 Resources 폴더 안에 있는 프리팹을 모두 검사해서 해당 프리팹이 어떤 카테고리(슬롯)을 의미하는지 정보를 넣어주었다.
2. 프리팹과 본(Bone) 연결
위에서 어느 부위에 착용하는 것인지 정의해주었다면
이제는 진짜 캐릭터에 옷을 입히는 로직을 넣어줄 것이다.
public void EquipById(string itemId)
{
if (!catalog || catalog.byId == null || !catalog.byId.TryGetValue(itemId, out var info))
{
Debug.LogWarning($"찾을 수 없는 않는 의류 id: {itemId}");
return;
}
Equip(info.prefab, info.slot);
}
옷을 입히려면 어느 옷의 프리팹인지 체크하고, 그 옷의 카테고리가 중복되지 않는지 체크하기 위한 코드이다.
이 때 TryGetValue 메서드를 활용해주었는데 딕셔너리에 두번 접근하는 ContainsKey의 단점을 보완한 메서드이다.
// ----- ContainsKey -----
if (catalog.byId.ContainsKey(itemId))
{
var info = catalog.byId[itemId];
Equip(info.prefab, info.slot);
}
else
{
Debug.LogWarning($"찾을 수 없는 의류 id: {itemId}");
return;
}
// ----- TryGetValue -----
if (!catalog.byId.TryGetValue(itemId, out var info))
{
Debug.LogWarning($"찾을 수 없는 의류 id: {itemId}");
return;
}
Equip(info.prefab, info.slot);
ContainsKey는 itemId(키값)에 접근하여 인덱스[]를 활용하여 info라는 새로운 변수에 prefab값과 slot 값을 넣어주는 반면
TryGetValue는 itemId 접근과 동시에 값이 있으면 info에 byId 딕셔너리의 Value인 prefab값과 slot값을 바로 넣어준다.
[HashSet]
중복을 제거해주는 자료구조(집합)
var clothingSet = new HashSet<Transform>(clothing.transform.GetComponentsInChildren<Transform>(true));
옷 오브젝트의 모든 자식 트랜스폼을 가져와서 HashSet에 저장
-> 이렇게 하면 중복 Transform이 자동으로 제거되어
본 매핑이 적절하게 이루어지지 않는 것을 방지하거나 존재 여부 등 검사를 빠르게 할 수 있다.
[리깅]
SkinnedMeshRenderer가 캐릭터 본을 따라 움직이려면 rootBone과 Bones 배열을 캐릭터 본으로 교체해야 한다.
https://docs.unity3d.com/510/Documentation/Manual/Preparingacharacterfromscratch.html
유니티 공식 문서를 확인해보면 휴머노이드 아바타 자체에는 본 구조가 정의되어있다.
Root 밑에 Hips, Head, Spine, Neck 등등.. 휴머노이드에 필요한 뼈대가 정의되어있다.

// 3) rootBone 매칭 (이름이 같으면 그대로 / 없으면 Hips 폴백)
if (clothing.rootBone && boneMap.TryGetValue(clothing.rootBone.name, out var mappedRoot))
{
clothing.rootBone = mappedRoot;
}
else
{
var hips = target.GetBoneTransform(HumanBodyBones.Hips);
if (hips) clothing.rootBone = hips;
}
즉 옷을 입혔을 때

캐릭터의 뼈대와 옷의 뼈대가 일치하지 않으면 이렇게 옷은 둥둥 떠있다는 뜻!
그래서 옷의 뼈대를 캐릭터 뼈대로 교체하는 작업을 해주었다.
[switch expression]
기존 스위치문을 간결하게 조건 -> 결과로 매핑하는 문법이다.
여기서 사용하는 =>는 람다식이 아니라 결과 지정으로 사용된다.
static Transform GetAttachBoneForSlot(SlotType slot, Animator anim)
{
if (!anim) return null;
return slot switch
{
SlotType.Hat or SlotType.Hair or SlotType.Glasses or SlotType.FaceAccessory
=> anim.GetBoneTransform(HumanBodyBones.Head),
SlotType.Top
=> anim.GetBoneTransform(HumanBodyBones.UpperChest)
?? anim.GetBoneTransform(HumanBodyBones.Chest)
?? anim.GetBoneTransform(HumanBodyBones.Spine),
SlotType.Bottom
=> anim.GetBoneTransform(HumanBodyBones.Hips),
SlotType.Shoes
=> anim.GetBoneTransform(HumanBodyBones.LeftFoot)
?? anim.GetBoneTransform(HumanBodyBones.Hips),
_ => null
};
}
switch (slot)
{
case SlotType.Hat:
case SlotType.Hair:
case SlotType.Glasses:
case SlotType.FaceAccessory:
return anim.GetBoneTransform(HumanBodyBones.Head);
default:
return null;
}
3. 버튼 로직 추가
1. Action / Event
- delegate (델리게이트) : 메서드를 변수처럼 가리키는 타입
- Action : void를 반환하는 델리게이트의 표준 타입
- event : 델리게이트에 발행자 -> 구독자 모델을 위한 안전장치를 씌운 것
외부는 += / -= 으로 구독/해지만 가능하고 발행(Invoke)는 선언한 클래스 내부에서만 가능하다.
이벤트를 활용한 이유
단순한 콜백 변수를 public으로 열어두면 누군가 =로 통째로 갈아치울 위험이 있음
event로 선언하면 구독/해지 외에는 못하게 막아서 발행자만 호출 가능
public class ClothesManager : MonoBehaviour
{
// 슬롯 하나 해제됨
public event System.Action<SlotType> Unequipped;
// 전부 해제됨
public event System.Action UnequippedAll;
public void Unequip(SlotType slot)
{
// ... 실제 해제 로직 ...
Unequipped?.Invoke(slot); // 안전 호출(구독자가 없으면 null)
}
public void UnequipAll()
{
// ... 전부 해제 로직 ...
UnequippedAll?.Invoke();
}
}
- ?.Invoke(...)는 구독자가 없을 때 NRE 방지
- 이벤트는 멀티캐스트라서 구독자가 여러 명이면 등록된 순서대로 호출되지만, 순서 보장은 계약이 아님(믿고 설계하지 말 것)
- 리스너 중 하나가 예외를 던지면 뒤에 있는 리스너 호출이 중단될 수 있음. 발행 측에서 필요하다면 try/catch로 방어
void OnEnable()
{
clothes ??= FindFirstObjectByType<ClothesManager>();
if (clothes)
{
clothes.Unequipped += ClearSelection; // 슬롯 하나 컬러 리셋
clothes.UnequippedAll += ClearAllSelections; // 전부 컬러 리셋
}
}
void OnDisable()
{
if (clothes)
{
clothes.Unequipped -= ClearSelection;
clothes.UnequippedAll -= ClearAllSelections;
}
}
- 메모리/참조 관리 포인트
- 이벤트는 구독자를 강하게 참조함 → OnDisable에서 반드시 해지 (해지 안 하면 오브젝트가 파괴되어도 GC가 수거 못 해서 메모리 누수/유령 콜백 가능)
- 익명 람다로 구독할 때는 같은 람다 인스턴스로 해지해야 함 (보통 메서드 그룹 += ClearSelection을 쓰면 안전)
- static event는 특히 누수에 취약
- 스레드: Unity 오브젝트 조작은 메인 스레드에서만! 다른 스레드에서 이벤트 터뜨리지 말 것
4. 주말동안 버그 해결!


