매개변수로서 SV_DispatchThreadID 를 받고 있는데, 쓰레드 하나가 실행될때마다 고유한 아이디가 발급되고, 저 매개변수를 통해 우리는 쓰레드별 고유한 인덱스를 받아올 수 있는 것이다.
만약 내가 64개의 스레드를 실행하라고 명령하면, id 에 0~63까지의 값이 전달된다는 것
스레드별 고유한 아이디를 이용해 각 큐브의 위치를 미리 계산할수 있다.
나는 여기서 이동행렬, 회전행렬을 미리 다 계산해서 렌더 셰이더에 넘겨줄 것이므로 행렬을 생성해서 RWStructuredBuffer 에 셋팅하고 있는 걸 확인할 수 있다.
여기까지 Compute Shader 의 코드설명이 끝났다.
요약하자면, 쓰레드가 처리할 일을 커널 함수에 구현하되, 이를 위해 외부로부터 변수를 전달받아야 하기 때문에 몇가지의 변수와 버퍼를 설정했다는 것
남은건 이제 이 컴퓨트 셰이더에 변수를 전달하고, 실행하라는 명령을 내리는 것뿐
이를 위해 InstancedProcedural.cs 파일을 몇군데 수정하자.
public class InstancedProcedural : MonoBehaviour
{
[SerializeField] private ComputeShader computeShader;
[SerializeField] private Material material;
[SerializeField] private Mesh mesh;
[SerializeField] private int count;
private ComputeBuffer positionBuffer;
private static readonly int PositionID = Shader.PropertyToID("_Positions");
private static readonly int ResolutionID = Shader.PropertyToID("_Resolution");
private static readonly int TimeID = Shader.PropertyToID("_Time");
private void Update()
{
positionBuffer?.Release();
positionBuffer = new ComputeBuffer(count * count, 4 * 16);
computeShader.SetInt(ResolutionID, count);
computeShader.SetFloat(TimeID, Time.time);
computeShader.SetBuffer(0, PositionID, positionBuffer);
int groups = Mathf.CeilToInt(count / 8f);
computeShader.Dispatch(0, groups, groups, 1);
material.SetBuffer(PositionID, positionBuffer);
Graphics.DrawMeshInstancedProcedural(
mesh, 0, material, new Bounds(Vector3.zero, Vector3.one), count * count
);
}
private void OnDisable()
{
positionBuffer?.Release();
positionBuffer = null;
}
}
일단 코드량이 더 줄었다. 왜냐하면 위치계산을 하던 부분이 컴퓨트 셰이더로 빠졌기 때문
변경된 부분만 살펴보자면,
[SerializeField] private ComputeShader computeShader;
//시리얼라이즈를 통해 위에서 만든 Compute Shader 를 연결해주고
positionBuffer = new ComputeBuffer(count * count, 4 * 16);
//기존에는 x,y,z 의 float 값 3개를 전달하기 위해 4 * 3 = 12 를 썼다면,
//지금은 4x4 행렬을 전달하기 위해 4 * 16 크기로 바꾸었다.
computeShader.SetInt(ResolutionID, count);
computeShader.SetFloat(TimeID, Time.time);
computeShader.SetBuffer(0, PositionID, positionBuffer);
//컴퓨트 셰이더에서 위치와 회전 계산을 위해 변수를 전달하고 있으며,
//계산한 결과를 받기 위해 ComputeBuffer 도 전달한다.
int groups = Mathf.CeilToInt(count / 8f);
computeShader.Dispatch(0, groups, groups, 1);
중요한 부분이다
컴퓨트 셰이더를 실행시키는 함수는 Dispatch인데
첫번째 인자에는 커널의 인덱스를 넣는다
미리 언질하지 않았지만, 하나의 컴퓨트 셰이더는 여러개의 커널을 가질 수 있다.
우리는 현재 커널을 하나만 선언했으므로 0을 넣는다.
이하 매개변수에는 그룹의 갯수를 설정해야 하는데,
아까 위에서 커널을 실행하는 단위는 그룹이라고 했었다.
이 그룹 또한 3차원으로 설정 가능한데, 저 세 매개변수를 모두 곱한게 총 그룹의 갯수가 된다.
자 현재 우리는 컴퓨트 셰이더에 총 64개의 스레드가 병렬 처리되도록 설정했었다 [numthreads(8,8,1)]
하지만 큐브를 count * count 만큼 그려야 하기 때문에 count * count 만큼의 스레드가 필요하단 얘기인데,
count / 8 을 하게 되면 1차원의 그룹 갯수가 도출되고, 이를 x, y 그룹 갯수에 설정하면, 총 돌려야 할 그룹 갯수를 산출할 수 있다.
예를 들어 설명해보자
만약 count 가 128 이라면, 큐브를 총 128 * 128 그려야 하므로 16,384 개의 스레드가 필요하다.
여기서 하나의 커널이 현재 8 * 8 * 1 = 64개의 스레드를 병렬처리 할 수 있으므로
16,384 / 64 하면 필요한 그룹의 갯수가 256개가 나온다.
여기서 Dispatch 함수에 256, 1, 1 로 전달할 수 있지만, 3차원으로도 그룹을 전달 할 수 있기 때문에
x와 y에다가 균일하게 분배하면 16, 16, 1 이렇게도 전달이 가능하다.
결국 요 16 이라는 숫자는 128 / 8 과 같으므로
int groups = count / 8 과 같다
추가적으로 count가 8의 배수가 아닐 경우도 있으므로 올림처리를 해서 그룹이 부족하지 않도록 넉넉히 잡아준 것
material.SetBuffer(PositionID, positionBuffer);
//이제 CubeShader.shader 에는 float3 형태가 아닌 float4x4 형태의 버퍼가 전달된다.
여기까지가 코드 변경부분이다.
렌더 셰이더 (CubeShader.shader) 에서는 전달받은 행렬을 통해 정점을 변환 시켜주면 되기 때문에 코드량도 확 줄게 된다.
씬 로딩화면을 구현할때 ResourceManager.LoadSceneAsync 같은 비동기 함수를 쓰더라도 로딩 중 화면이 끊기는 현상을 자주 접해봤을 것이다.
하지만 로딩화면이야 뭐 사용자가 조작도 할 수 없고 현상도 도드라져 보이지 않게끔 움직이는 UI를 최대한 안쓴다던지 하는 방법으로 타협이 가능한데, 조작 중 끊기는 건 그냥 넘어갈 수가 없다.
FPS 나 액션 게임같은 경우 렉 한방에 파티가 전멸하거나 내 케릭터가 사망하는 경험을 한번이라도 해보았다면, 그 중요성을 실감할 수 있을 것이다.
그래서 우리는 멀티 스레딩을 대신할 방법을 동원해 이를 해결해야 하는데,
첫번째가 Job System, 두번째는 Compute Shader 이다.
이 둘 중 어느 것을 써야 하는지는 병렬 처리 특성에 따라 다르다.
Job System 은 Main Thread 가 아닌 별도의 Job Thread ( 유니티에서의 명칭은 WorkerThread 이므로 이후 WorkerThread라고 명명 ) 를 이용해 CPU가 할일을 다른 CPU 코어한테 나눠주는 것이고, (최신 CPU는 코어 갯수가 최소 3~8개이다) Compute Shader 는 GPU한테 그 역할을 맡기는 것이다.
Profiler 상에서의 Worker Thread
만약 자신의 프로젝트가 CPU는 바쁜데 GPU는 비교적 널널할 경우 우선 Compute Shader 도입을 고려해야 한다.
GPU는 비용이 작은 스레드를 동시에 처리하는 방식에 특화되어 있기 때문에 스레드 간 데이터 종속성이 없고, 하나의 스레드에 무거운 연산을 하지 않는다면 Job System 보다 효과적인 결과를 얻을 수 있다.
이에 반해 어? 우리는 CPU도 바쁘고 GPU도 바쁜데...이러면 선택의 여지없이 Job System 을 도입해야 한다.
또한 스레드 갯수는 상대적으로 적고 하나의 스레드에서 무거운 연산을 해야 한다면 이 또한 Job System을 도입하는게 유리하다.
개념 설명은 여기까지 하고 우리는 프로그래머니까 코드로 설명하는게 확 와닿을 것이다.
먼저 병목 현상을 인위적으로 만들어 낸 후 이를 Compute Shader 와 Job System 으로 해결하면서 최적화 된 결과를 도출해내는 과정을 설명하겠다.
먼저 단순히 40000 개의 큐브를 월드에 뿌려보자
using UnityEngine;
public class GeneralInstancing : MonoBehaviour
{
[SerializeField] private GameObject instancingTarget;
[SerializeField] private int count = 200;
private void Start()
{
for (int i = 0; i < count; ++i)
{
for (int j = 0; j < count; ++j)
{
GameObject instanced = Instantiate(instancingTarget);
instanced.gameObject.SetActive(true);
float x = -(count * 0.5f) + i;
float z = -(count * 0.5f) + j;
instanced.transform.position = new Vector3(x * 2, 0, z * 2);
}
}
}
}
Scene View, Hierarchy View큐브 생성 후 Collider는 제거해주자
실제 실행하면 다음과 같이 FPS가 확연히 떨어짐을 체감할 수 있다. (수치값은 PC마다 다를 수 있음)
40000만개의 큐브
프로파일러로 확인해보면 RenderLoop.ScheduleDraw() 에서 많은 시간을 할애하고 있는데, 이는 CPU가 GPU에게 40000 번의 드로우콜을 요청하는데 이만큼의 시간이 걸리고 있다는 것을 의미한다. ( = CPU 병목)
녹색의 RenderLoop.ScheduleDraw 가 확연히 길다.
그렇다면, CPU를 거치지 않고 우리가 직접 GPU한테 그리라고 명령을 내리면 이 시간이 걸리지 않을테니, 병렬처리 기법에 대해 알아보기 전에 이 작업을 GPU 에게 넘겨보자.
일단 GPU한테 작업을 넘겨야 그 뒤에 GPU한테 병렬처리를 맡길 수 있으니 일단 해보자
using UnityEngine;
public class InstancedProcedural : MonoBehaviour
{
[SerializeField] private Material material;
[SerializeField] private Mesh mesh;
[SerializeField] private int count;
private ComputeBuffer positionBuffer;
private Vector3[] positions;
private static readonly int Positions = Shader.PropertyToID("_Positions");
private void Start()
{
positions = new Vector3[count * count];
}
private void Update()
{
positionBuffer?.Release();
positionBuffer = new ComputeBuffer(count * count, 12);
for (int i = 0; i < count; ++i)
{
for (int j = 0; j < count; ++j)
{
float x = (-(count * 0.5f) + i) * 2;
float z = (-(count * 0.5f) + j) * 2;
positions[(count * i) + j] = new Vector3(x, 0f, z);
}
}
positionBuffer.SetData(positions);
material.SetBuffer(Positions, positionBuffer);
Graphics.DrawMeshInstancedProcedural(
mesh, 0, material, new Bounds(Vector3.zero, Vector3.one), count * count
);
}
private void OnDisable()
{
positionBuffer?.Release();
positionBuffer = null;
}
}
개념은 4만개의 큐브의 위치를 미리 설정해놓고, Graphics.DrawMeshInstancedProcedural 함수를 통해 GPU한테 직접 그리란 명령을 실행하는 것이다.
해당 함수의 매개변수를 보면 Mesh, SubMeshIndex, Material, Bounds, Count 로서 포지션을 셋팅할 수 있는 부분이 없다.
아니 그럼 어떻게 여기에 각각의 큐브의 포지션을 전달합니까?
이를 위해 ComputeBuffer가 필요하다.
ComputeBuffer 생성시 매개변수로, 생성할 갯수와 버퍼의 사이즈를 셋팅해줘야 하는데
매개변수에 count 와 stride
4만개의 큐브의 포지션 값을 저장할 것이므로 Count는 4만이고, 사이즈는 바이트 값으로서, float 값 3개 (x, y, z) 를 전달하면 되니까
float (= 4byte) * 3개 = 12 byte
즉 12 를 셋팅해 주면 된다.
이 버퍼를 통해 포지션값을 전달해주면, 쉐이더 내부에서 이 포지션 값을 참조해서 정점에다 셋팅해주면 끝
GameView의 Statistics 창을 보면 DrawCall 을 나타내는 지표로 Batches 와 SetPass Calls 가 있다.
아니, DrawCall 횟수가 없네?
사실 몇달전 면접질문 중 하나가 유니티의 DrawCall 을 Batch와 SetPass Call 개념을 통해 설명해보라 였는데 대답을 못했었다.
문득문득 이불킥 감이었는데, 얼마전 오지현님의 URP 관련 동영상 강의를 보면서 개념에 대해 정리하게 되었다.
먼저 DrawCall 에 대해 설명하자면,
CPU가 GPU한테 그려라! 명령을 내리는 작업이다.
보통 그래픽 최적화 시, 프레임당 드로우콜이 몇번 발생했느냐를 따질때 그 드로우콜인데, 유니티에서는
그려라! 라는 것(DrawCall) + SetPass Call 을 Batch 숫자로 환산한다.
자 그럼 이 Set Pass Call 이 무엇인지만 알면 Batch 가 무엇인지 확실히 알게 되는데, 이 개념이 사실 쉽게 와닿지 않았다(최소한 나에겐)
CPU가 GPU에게 내리는 명령은 사실 DrawCall 뿐만이 아니다.
CPU는 GPU에게 명령할때 Command Buffer 라는 곳에 여려 명령들을 모아놨다가, 이 버퍼 자체를 전달하는데 이때 여려 명령들 중에 하나가 드로우 콜이다.
유니티 코리아 유투브 채널의 [Dev Weeks : 성능을 고려한 파이프라인. Universal Render Pipeline] 세션 에서 캡쳐한 커맨드 버퍼의 개념
보다시피 드로우콜은 수많은 커맨드 중 하나일뿐이고, 특정 커맨드들을 묶어서 Set Pass Call 로 명명한다.
왜 얘네들만 따로 묶어서 명명하지?
그 이유는 저 커맨드들이 GPU에 부하를 주는 주범이기 때문
이 커맨드들의 공통점은 GPU에 상태값을 전달하는 명령이라는 것인데, 만약 같은 메테리얼을 가진 오브젝트 10개를 그린다고 가정했을때 첫번째 그리는 오브젝트에서 DrawCall + Set Pass Call 이 호출되고나면 두번째 오브젝트부터는 DrawCall 만 GPU에 전달하면 된다.
왜냐면 텍스쳐나, 쉐이더 상수, 쉐이더 코드, 블렌딩 공식등 (Set Pass Call 에 해당하는 명령들) 은 첫번째 오브젝트 그릴때 전달했던 값을 그대로 쓰면 되니까
하지만 만약 다른 메테리얼을 가진 오브젝트를 그려야 하면 이전에 전달한 값을 그대로 쓸수 없을 것이니 저 Set Pass Call 에 해당하는 명령들을 GPU에 다시 전달해야 할것이다. 문제는 저 명령들이 GPU 입장에서 비용이 많이 드는 작업이라는 것.
그럼 위 결과를 토대로 본다면, 저 Set Pass Call 을 줄일수록 GPU에 주는 부하가 적게 들것이고, 이는 곧 최적화와 연결된다.
Set Pass Call 을 최대한 적게 호출하면서 Draw Call을 수행하는 것.
유니티에서는 이를 배칭 이라고 표현한다.
자 이제 아래의 Statistics 창에서 왜 Batches 와 Set Pass Calls 이 표현되는지 이해가 가는가?
우리의 최적화의 최종 목표는 Batches 에 비해 Set Pass Calls 를 최대한 줄이는 것 (Batches - SetPass Calls 값이 적을수록 최적화가 잘 되었단 의미)
그럼 어떻게 하면 SetPass 를 줄일 수 있죠?
일단 단순히 메테리얼을 적게 쓰면 된다. (= 메테리얼 베리언트 수를 적게 유지할것)
메테리얼 교체 비용이 많이 발생하지 않도록 renderer 옵션의 sorting layer 를 통해 동일한 메테리얼들을 하나로 묶어서 그리게 한다
Dynamic 배칭과 Static 배칭을 적극 활용한다 (이 두가지 배칭에 대한 개념 및 차이는 본 블로그 주제와 다르므로 설명 생략)
하지만 만약 Dynamic 배칭이 일어날 확률이 극히 적을 경우엔 아예 Dynamic 배칭 기능을 끄는게 좋다고 한다
Dynamic 배칭 대상인지 아닌지 확인하는 작업도 비용소모가 발생
위 내용을 기반으로 다른 문제를 하나 더 생각해보자
만약 서로 다른 메테리얼이지만 동일한 쉐이더를 쓰는 경우를 가정해보자 (A 메테리얼의 색깔은 빨간색, B 메테리얼의 색깔은 파란색으로 하고 싶다)
A 메테리얼 - Shader A
B 메테리얼 - Shader A
이 경우 Batch 와 SetPass Call 은 각각 2 개로서 동일한 쉐이더를 쓰지만 둘은 서로 메테리얼로 인식해서 배칭이 이루어지지 않을 것이다.
하.지.만 유니티 에서는 SRP Batcher 기능을 켜면 위와 같은 상황에서 배칭이 된다 (!!!)
원리는 간단한데, GPU 메모리에다가 저 SetPass Call을 통해 전달받은 값을 저장해놨다가, 나중에 동일한 값이 들어왔을때 그 값을 바로 참조해서 쓰는 것이다. 이에 대한 자세한 개념은 위에 소개한 동영상에서 확인 할 수 있다.
단점은 저 값을 저장해야 하니까 GPU 메모리를 먹는다는 것인데, 효과에 비한다면 엎드려 절이라고 하고싶은 기능이다.
유니티에서 충돌을 처리하기 위해선 rigidbody와 collider를 Object에 붙여야 한다.
하지만 프로젝트마다 충돌의 목적 및 기대결과가 다르고 두 컴포넌트간의 옵션값 설정에 따라 충돌감지 및 물리현상 발현 유무가 다르기 때문에 충돌에 대해 정확히 정리해야 할 필요가 있다.
충돌감지
양쪽 오브젝트에 Collider추가
Collider와 Collider2D는 서로 감지가 안되므로 Collider는 Collider끼리 Collider2D는 Collider2D 끼리 추가
충돌을 감지할 오브젝트에 rigidbody 추가
위에서 2D를 달앗으면 rigidbody도 2D를 달아야함
꼭 두 오브젝트 모두에 rigidbody를 달 필요는 없음
rigidbody가 달린 오브젝트에 monobehaviour 스크립트 추가하고 다음 세가지 함수 추가
OnCollisionEnter - 다른 어떤 Collider가 내 Collider 영역으로 들어왔을때 호출됨
OnCollisionStay - 다른 어떤 Collider가 내 Collider 영역에 있을때 호출됨
OnCollisionExit - 다른 어떤 Collider가 내 Collider 영역에 있다가 나갔을때 호출됨
기본은 이렇게만 하면 위 세가지 함수가 정상적으로 호출되며 충돌감지시 처리를 하면 되는데, rigidbody 옵션과 collider 의 isTrigger 옵션에 따라 함수 호출이 안되는 경우가 있다. 이럴때 아래 옵션값을 확인하며 체크를 해보자
rigidbody2D의 Simulated 가 체크되어 있는가? (다른 충돌체와 상호작용을 원하지 않을때는 체크해제한다)
rigidbody의 Kinematic 속성이 켜져 있을 경우 순전히 운동성(속도, 가속도, 위치)만 가지므로 위의 OnCollisionEnter, Stay, Exit 함수도 호출되지 않는다 (충돌이 일어나지 않으므로 서로 물리법칙에 의해 튕겨 나가는 등의 인터렉션이 없다 !!)
Kinematic 을 유지한채 충돌감지를 하고 싶을땐
Collider의 isTrigger 옵션을 켠다.
OnTriggerEnter, OnTriggerStay, OnTriggerExit 를 사용한다.
OnTrigger, OnCollision 두 종류 함수는 결코 같이 호출되는 일이 없다.
결론
만약 두 오브젝트를 중력이나 충돌상호작용 등 물리법칙에 영향받지 않고 충돌감지만 하고 싶을때는 Rigidbody의 Kinematic을 켜고, Collider의 isTrigger 옵션을 체크한 후 OnTriggerOOO 함수를 Rigidbody가 달린 스크립트에 추가하자
Mac 에서 vscode로 Unity 프로젝트를 열었는데, 어느날 갑자기 다음과 같은 창이 뜨며 프로젝트를 불러오지 못했다.
그래서 Show Output을 클릭했더니 Omnisharp 콘솔창에 아래와 같은 메세지를 발견
The reference assemblies for .NETFramework,Version=v4.7.1 were not found. To resolve this, install the Developer Pack (SDK/Targeting Pack) for this framework version or retarget your application. You can download .NET Framework Developer Packs athttps://aka.ms/msbuild/developerpacks
확인결과 vscode 의 최신 C# Extension 이 해당 버전의 프레임웍을 불러오지 못해 발생한 이슈였다.
해결법은 2가지라고 하는데 첫번째는 최신 mono를 깔기, 두번째는 C# Extension 다운그레이드
가상의 bundle을 빌드해서 그 bundle을 통해 Asset에 접근하는 방식으로, bundle간 dependency가 제대로 되어있는지, 모바일로 빌드했을때 참조 에러가 나지 않는지 editor 환경에서 bundle 참조확인이 가능하므로, 빌드 직전 리소스 테스트 단계에 유리
Use Existing Build(requires built groups)
스크립트를 통해 빌드를 이미 수행했다는 전제하에, 물리적으로 존재하는 bundle을 직접 참조해서 Asset에 접근하는 방식
개발 중 직접 bundle을 빌드해서 확인하고자 할떄 사용 (local build test에 유리)
catalog와 settings 파일은 빌드시에 Project Folder/Library/com.unity.addressables/StreamingAssetCopy/aa/[BuildTarget]에 만들어지므로 참조경로도 여기에 설정되어 있음
이미 있는 bundle을 이용하는 모드이기 때문에 생성과정이 없으므로 실행시 가장 빠르다
각 프로젝트마다 위의 mode를 사용하지 않아야 할 떄가 있다. 가령 서버에 올라가 있는 번들을 받아서 돌리고 싶을때나, 버전 관리를 위해 json파일에 버전을 추가한다거나 할때 script를 커스터마이징 해야 하는데 이를 위해 Addressables가 번들 로드시 참조하는 값이 무엇인지, 그리고 각 파일들이 어떻게 연결되어 있는지 파악해야 함
가령 한 PC 에서 bundle빌드를 돌린다고 가정
Addressables Group 창에서 Build -> New Build -> Default Build Script를 실행
Project Folder/Library/com.unity.addressables/StreamingAssetCopy/aa/[BuildTarget] 에 settings.json, catalog.json, link.xml이 생성됨
settings.json
이 프로젝트가 참조해야 할 catalog파일이 어디 존재하는지 명시
hash파일과 remote hash 파일이 어디 존재하는지 명시
기타 Addressables 관련 환경변수가 저장되어 있음
catalog.json
실질적인 bundle들의 정보로서 모든 addressables의 이름, 경로, Key, Resource Type 이 정의되어 있음
해당 파일을 이용하면 이전 catalog와의 diff를 통해 자제적으로 버전관리도 가능할 수준의 detail하고 substantial한 정보
link.xml
본 프로젝트가 참조하고 있는 모든 라이브러리의 정보를 담고 있음
editor 환경에서는 사용되지 않고, 모바일 빌드시 library포함여부에 관여
결국 settings.json과 catalog.json 파일만 잘 만들어준다면, Addressables 커스터마이징을 할 수 있다는 의미
Remote Version 관리
Remote Path
서버의 catalog.json, catalog.hash 파일과 로컬의 catalog.json 파일을 비교해서 업데이트된bundle만 서버에서 선별해 받을 수 있는 Addressables Remote 시스템
Firebase 를 통한 Addressables Remote
문서를 만들게 된 계기가 된 항목으로서 firebase에 bundle을 업로드 한 후 RemoteLoadPath에 gs:// 로 시작하는 링크를 연결시 파일을 받아오지 못하는 문제가 발생
기본적으로 Unity는 http, https로 시작하는 주소만 remote path라고 인식을 하기 때문에 기존 Addressables 에 대한 커스터마이징이 필요하게 됨
해결법
문제 해결을 위해선 LoadAsset을 통한 bundle요청시 gs://로 시작하는 주소를 https://주소로 변환한 다음 서버에 요청하고 그 결과를 반환해줘야 함
Addressables 에서는 이를 위해 AssetbundleProvider라는 함수를 제공
단 위 함수는 Addressables Group의 bundle로드시에만 동작하는 함수이기 때문에 Addressables의 주요 기능인 DownloadDependencyAsync 함수를 쓰기 위해선 별도의 방법이 필요
Addressables.InternalIdTransformFunc 함수는 provideHandle.ResourceManager.TransformInternalId 함수 호출시 타도록 Unity가 제공하는 API
이름에서도 알 수 있듯 개발자들이 Provider 에서 TransformId를 입맛대로 수정이 용이하도록 제공한 함수로서 v.1.75부터 제공한다
이를 이용해 서버에 존재하는 .hash, .json 로드시에도 서버에 gs protocol을 날리기 전에 동작을 가로채어 변환후 넘겨줄 수 있다.
Provider 함수 내부의 Provider() 를 override 해서 internal id 를 바꾼 후 provideHandle.ResourceManager.ProvideResource() 함수를 호출하고 Complete 성공 시 provideHandle.Complete() 를 호출해서 Unity에 넘겨준다