빌보드(Billboard)는 3D 오브젝트가 카메라를 바라보는 기술입니다.
일반적으로 유니티 파티클 시스템을 생성하면 나오는 오브젝트들이 기본적으로 빌보드로 되어 있습니다.
이번 포스팅은 Vertex 쉐이더를 기반으로 빌보드를 직접 구현 및 원리 분석에 대해 작성하겠습니다.
기본 hlsl 쉐이더 세팅
struct appdata
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert(appdata v)
{
v2f o = (v2f)0;
float4 positionOS = v.positionOS;
float4 positionWS = mul(UNITY_MATRIX_M, float4(positionOS.xyz, 1));
float4 positionVS = mul(UNITY_MATRIX_V, positionWS);
float4 positionCS = mul(UNITY_MATRIX_P, positionVS);
o.positionCS = positionCS;
o.uv = v.uv;
return o;
}
float4 frag(v2f i) : SV_Target
{
float2 mainTexUV = i.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw;
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, mainTexUV);
return col * _Color;
}
기본 hlsl 쉐이더 코드입니다.
특이한점은 버텍스 쉐이더에서 기존 TransformObjectToHClip함수를 사용하지 않고 직접 Model View Projection 좌표계 변환 코드를 작성했습니다.
MVP 좌표계 변환이란 간단히 말해서, 유니티 오브젝트의 Transform 좌표, 회전, 크기 값을 설정한대로 화면에 보이도록 수학 계산하는 과정입니다.
위 MVP 변환 과정을 커스텀하여 빌보드를 구현하는 것이 핵심입니다.
카메라 위치 기반 빌보드 구현
오브젝트가 카메라를 바라보게 하여 빌보드를 구현해보겠습니다.
float3 pivotPosOS = float3(0, 0, 0);
float4 pivotPosWS = mul(UNITY_MATRIX_M, float4(pivotPosOS.xyz, 1));
float4 cameraPosWS = float4(_WorldSpaceCameraPos.xyz, 1);
float3 cameraLookDir = normalize(pivotPosWS.xyz - cameraPosWS.xyz); // 카메라 방향 벡터 계산
먼저 오브젝트의 월드 기준 위치(PivotWS)와 카메라 월드 기준 위치(_WorldSpaceCameraPos)를 뺄셈 연산하여 카메라 방향 벡터를 계산합니다.
두 벡터를 뺄셈하면 두점 사이의 방향을 계산하는 식입니다.
// 카메라 방향 벡터를 회전 행렬로 변환
float4x4 cameraRotMatrix;
float3 upVector = float3(0, 1, 0);
float3 rotM2 = cameraLookDir;
float3 rotM0 = normalize(cross(upVector, rotM2));
float3 rotM1 = cross(rotM2, rotM0);
cameraRotMatrix[0] = float4(rotM0.x, rotM1.x, rotM2.x, 0);
cameraRotMatrix[1] = float4(rotM0.y, rotM1.y, rotM2.y, 0);
cameraRotMatrix[2] = float4(rotM0.z, rotM1.z, rotM2.z, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
카메라 방향 벡터를 회전 행렬(Matrix)로 변환합니다.
왜냐하면 Model 행렬 변환에 카메라 회전 행렬을 적용해서 오브젝트가 카메라를 바라보게 만들 것이기 때문입니다.
벡터 -> 회전행렬 참고 자료 - https://forum.unity.com/threads/problem-with-lookat-vertex-shader-model-space.384903/
// 월드 공간에서 회전행렬 계산 후 다시 로컬 공간으로 복귀
positionOS = mul(transpose(UNITY_MATRIX_M), positionOS);
positionOS = mul(cameraRotMatrix, positionOS);
positionOS = mul(transpose(UNITY_MATRIX_I_M), positionOS);
float4 positionWS = mul(UNITY_MATRIX_M, float4(positionOS.xyz, 1));
다음 회전 행렬을 Model 행렬 변환에 적용하는 과정입니다.
회전행렬은 월드 공간 기준으로 계산했기 때문에 오브젝트 공간의 위치 값을 월드 공간으로 바꾸고 회전행렬을 계산합니다. (1, 2줄)
위 처럼 공간 변환을 거꾸로 되돌릴때는 역행렬 계산을 해야합니다. 역행렬 계산을 하기 위해서는 행렬 곱셈을 거꾸로 하거나, transpose 함수로 전치행렬 변환하여 계산합니다.
v2f vert(appdata v)
{
v2f o = (v2f)0;
float4 positionOS = v.positionOS;
float3 pivotPosOS = float3(0, 0, 0);
float4 pivotPosWS = mul(UNITY_MATRIX_M, float4(pivotPosOS.xyz, 1));
float4 cameraPosWS = float4(_WorldSpaceCameraPos.xyz, 1);
float3 cameraLookDir = normalize(pivotPosWS.xyz - cameraPosWS.xyz); // 카메라 방향 벡터 계산
// 카메라 방향 벡터를 회전 행렬로 변환
float4x4 cameraRotMatrix;
float3 upVector = float3(0, 1, 0);
float3 rotM2 = cameraLookDir;
float3 rotM0 = normalize(cross(upVector, rotM2));
float3 rotM1 = cross(rotM2, rotM0);
cameraRotMatrix[0] = float4(rotM0.x, rotM1.x, rotM2.x, 0);
cameraRotMatrix[1] = float4(rotM0.y, rotM1.y, rotM2.y, 0);
cameraRotMatrix[2] = float4(rotM0.z, rotM1.z, rotM2.z, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
// 월드 공간에서 회전행렬 계산 후 다시 로컬 공간으로 복귀
positionOS = mul(transpose(UNITY_MATRIX_M), positionOS);
positionOS = mul(cameraRotMatrix, positionOS);
positionOS = mul(transpose(UNITY_MATRIX_I_M), positionOS);
float4 positionWS = mul(UNITY_MATRIX_M, float4(positionOS.xyz, 1));
float4 positionVS = mul(UNITY_MATRIX_V, positionWS);
float4 positionCS = mul(UNITY_MATRIX_P, positionVS);
o.positionCS = positionCS;
o.uv = v.uv;
return o;
}
전체 코드
오브젝트가 카메라를 바라보게 하니깐, 빌보드 역할을 하게 됬습니다.
카메라 시선 방향 기반 빌보드 구현
파티클 시스템에서 동작하는 빌보드는 카메라의 시선 방향에 반응하는 반면, 카메라 위치 기반 빌보드는 반응하지 않습니다.
다음은 카메라 시선 방향 기반의 빌보드를 구현해보겠습니다.
// View 메트릭스를 회전 행렬로 사용
float4x4 cameraRotMatrix;
cameraRotMatrix[0] = float4(UNITY_MATRIX_V._m00, UNITY_MATRIX_V._m01, UNITY_MATRIX_V._m02, 0);
cameraRotMatrix[1] = float4(UNITY_MATRIX_V._m10, UNITY_MATRIX_V._m11, UNITY_MATRIX_V._m12, 0);
cameraRotMatrix[2] = float4(UNITY_MATRIX_V._m20, UNITY_MATRIX_V._m21, UNITY_MATRIX_V._m22, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
float4 positionOS = v.positionOS;
// 월드 공간에서 회전행렬 계산 후 다시 로컬 공간으로 복귀
positionOS = mul(transpose(UNITY_MATRIX_M), positionOS);
positionOS = mul(transpose(cameraRotMatrix), positionOS); // 역행렬 계산으로 View 공간을 월드 공간으로 변환
positionOS = mul(transpose(UNITY_MATRIX_I_M), positionOS);
float4 positionWS = mul(UNITY_MATRIX_M, float4(positionOS.xyz, 1));
float4 positionVS = mul(UNITY_MATRIX_V, positionWS);
float4 positionCS = mul(UNITY_MATRIX_P, positionVS);
o.positionCS = positionCS;
o.uv = v.uv;
return o;
이전과 원리는 동일하지만, View 공간 행렬을 역행렬 계산하여 오브젝트가 카메라 뷰 행렬을 따라가게 만듭니다.
그 다음 이전과 동일하게 월드 공간으로 바꿔서 행렬 계산하고 다시 오브젝트 공간으로 복구합니다.
파티클 시스템 빌보드와 완전 동일하게 동작합니다.
이슈 - 월드 Transform이 변하면 빌보드가 제대로 동작하지 않음
현재 빌보드가 작동하면서 Transform의 변화가 반영됩니다.
또한, 오브젝트 계층을 만들어서 위 처럼 Transform을 조정하면 매우 난리납니다.
그래서 현재 상태로는 Rotation 값을 0으로 고정하지 않으면 사용 불가능합니다.
이제 위 이슈를 해결해보겠습니다.
Model 행렬 계산을 커스텀하여 월드 Rotation 값을 무시하기
기존에 유니티에서 제공하는 UNITY_MATRIX_M 행렬을 그대로 이용하여 월드 Transform의 값이 반영되었습니다.
UNITY_MATRIX_M 행렬을 분해해서, 회전 행렬 부분을 제거하여 위 이슈를 해결할 것 입니다.
v2f vert(appdata v)
{
v2f o = (v2f)0;
float4 positionOS = v.positionOS;
// 월드 공간 크기 행렬 계산
float4x4 scaleMatrix;
float4 sx = float4(UNITY_MATRIX_M._m00, UNITY_MATRIX_M._m10, UNITY_MATRIX_M._m20, 0);
float4 sy = float4(UNITY_MATRIX_M._m01, UNITY_MATRIX_M._m11, UNITY_MATRIX_M._m21, 0);
float4 sz = float4(UNITY_MATRIX_M._m02, UNITY_MATRIX_M._m12, UNITY_MATRIX_M._m22, 0);
float scaleX = length(sx);
float scaleY = length(sy);
float scaleZ = length(sz);
scaleMatrix[0] = float4(scaleX, 0, 0, 0);
scaleMatrix[1] = float4(0, scaleY, 0, 0);
scaleMatrix[2] = float4(0, 0, scaleZ, 0);
scaleMatrix[3] = float4(0, 0, 0, 1);
// 월드 공간 회전 행렬 계산
float4x4 rotationMatrix;
rotationMatrix[0] = float4(UNITY_MATRIX_M._m00 / scaleX, UNITY_MATRIX_M._m01 / scaleY, UNITY_MATRIX_M._m02 / scaleZ, 0);
rotationMatrix[1] = float4(UNITY_MATRIX_M._m10 / scaleX, UNITY_MATRIX_M._m11 / scaleY, UNITY_MATRIX_M._m12 / scaleZ, 0);
rotationMatrix[2] = float4(UNITY_MATRIX_M._m20 / scaleX, UNITY_MATRIX_M._m21 / scaleY, UNITY_MATRIX_M._m22 / scaleZ, 0);
rotationMatrix[3] = float4(0, 0, 0, 1);
float4x4 moveMatrix;
// 월드 공간 위치 행렬 계산
moveMatrix[0] = float4(1, 0, 0, UNITY_MATRIX_M._m03);
moveMatrix[1] = float4(0, 1, 0, UNITY_MATRIX_M._m13);
moveMatrix[2] = float4(0, 0, 1, UNITY_MATRIX_M._m23);
moveMatrix[3] = float4(0, 0, 0, UNITY_MATRIX_M._m33);
float4x4 modelMatrix = mul(mul(moveMatrix, rotationMatrix), scaleMatrix);
float4 positionWS = mul(modelMatrix, float4(positionOS.xyz, 1));
float4 positionVS = mul(UNITY_MATRIX_V, positionWS);
float4 positionCS = mul(UNITY_MATRIX_P, positionVS);
o.positionCS = positionCS;
o.uv = v.uv;
return o;
}
먼저 Model 행렬을 분해한 코드입니다.
기존 float4 positionWS = mul(UNITY_MATRIX_M, float4(positionOS.xyz, 1)); 코드가 위 처럼 길게 분해된것 입니다.
참고 자료 - https://www.sysnet.pe.kr/2/0/11637
테스트 삼아 rotationMatrix 대신 1을 넣고 월드 Rotation을 돌려보면 반응하지 않습니다.
// 월드 공간 회전 행렬 계산
float4x4 rotationMatrix;
float4x4 cameraRotMatrix;
// View 메트릭스(카메라 회전 행렬)를 회전 행렬로 사용
cameraRotMatrix[0] = float4(UNITY_MATRIX_V._m00, UNITY_MATRIX_V._m01, UNITY_MATRIX_V._m02, 0);
cameraRotMatrix[1] = float4(UNITY_MATRIX_V._m10, UNITY_MATRIX_V._m11, UNITY_MATRIX_V._m12, 0);
cameraRotMatrix[2] = float4(UNITY_MATRIX_V._m20, UNITY_MATRIX_V._m21, UNITY_MATRIX_V._m22, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
rotationMatrix = transpose(cameraRotMatrix); // View 공간을 월드 공간 기준으로 계산하기 위해 전치행렬로 변환하여 역행렬 계산하게 세팅.
위 회전 행렬 코드 부분을 이전에 만든 카메라 회전 행렬 기반 빌보드 코드를 넣었습니다.
Transform이 변화되도 빌보드가 정상작동하게 되었습니다.
Yaw축 고정하기
빌보드에서 Yaw축만 회전하게 하는 기능이 있습니다.
// 월드 공간 회전 행렬 계산
float4x4 rotationMatrix;
float4x4 cameraRotMatrix;
// View 메트릭스(카메라 회전 행렬)를 회전 행렬로 사용
cameraRotMatrix[0] = float4(UNITY_MATRIX_V._m00, UNITY_MATRIX_V._m01, UNITY_MATRIX_V._m02, 0);
cameraRotMatrix[1] = float4(UNITY_MATRIX_V._m10, UNITY_MATRIX_V._m11, UNITY_MATRIX_V._m12, 0);
cameraRotMatrix[2] = float4(UNITY_MATRIX_V._m20, UNITY_MATRIX_V._m21, UNITY_MATRIX_V._m22, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
cameraRotMatrix[1] = float4(0, 1, 0, 0); // Yaw축 빌보드
rotationMatrix = transpose(cameraRotMatrix); // View 공간을 월드 공간 기준으로 계산하기 위해 전치행렬로 변환하여 역행렬 계산하게 세팅.
회전 행렬의 카메라 방향 Y값을 고정하면 구현 가능합니다.
cameraRotMatrix[1] = float4(0, 1, 0, 0); // Yaw축 빌보드
// 월드 포지션 기준 카메라 방향 벡터 계산
float3 pivotPosOS = float3(0, 0, 0);
float4 pivotPosWS = mul(UNITY_MATRIX_M, float4(pivotPosOS.xyz, 1));
float4 cameraPosWS = float4(_WorldSpaceCameraPos.xyz, 1);
float3 cameraLookDir = normalize(pivotPosWS.xyz - cameraPosWS.xyz);
cameraLookDir.y = 0.0f; // Yaw축 빌보드
// 카메라 방향 벡터를 회전 행렬로 계산
float3 upVector = float3(0, 1, 0);
float3 rotM2 = cameraLookDir;
float3 rotM0 = normalize(cross(upVector, rotM2));
float3 rotM1 = cross(rotM2, rotM0);
float4x4 cameraRotMatrix;
cameraRotMatrix[0] = float4(rotM0.x, rotM1.x, rotM2.x, 0);
cameraRotMatrix[1] = float4(rotM0.y, rotM1.y, rotM2.y, 0);
cameraRotMatrix[2] = float4(rotM0.z, rotM1.z, rotM2.z, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
rotationMatrix = cameraRotMatrix;
카메라 위치 기반 빌보드에서는 카메라 방향 벡터의 y값을 고정하여 구현 가능합니다.
cameraLookDir.y = 0.0f;
코드 정리 & 전체 코드
Shader "CatDarkGame/BillboardShader"
{
Properties
{
_MainTex ("Main Texture", 2D) = "white" {}
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Tags { "RenderType" = "Opaque" "Queue"="Geometry" "RenderPipeline" = "UniversalPipeline" }
Pass
{
Name "URPUnlit"
Tags {"LightMode" = "SRPDefaultUnlit"}
Cull Off
HLSLPROGRAM
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#define _BILLBOARD_MODE_OFF 0
#define _BILLBOARD_MODE_POS 1
#define _BILLBOARD_MODE_ROT 2
#define _BILLBOARD_MODE _BILLBOARD_MODE_ROT
#define _BILLBOARD_ONLYYAW 0
#pragma prefer_hlslcc gles
#pragma exclude_renderers d3d11_9x
#pragma vertex vert
#pragma fragment frag
struct appdata
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
CBUFFER_START(UnityPerMaterial)
half4 _MainTex_ST;
float4 _Color;
CBUFFER_END
v2f vert(appdata v)
{
v2f o = (v2f)0;
float4 positionOS = v.positionOS;
// 월드 공간 크기 행렬 계산
float4x4 scaleMatrix;
float4 sx = float4(UNITY_MATRIX_M._m00, UNITY_MATRIX_M._m10, UNITY_MATRIX_M._m20, 0);
float4 sy = float4(UNITY_MATRIX_M._m01, UNITY_MATRIX_M._m11, UNITY_MATRIX_M._m21, 0);
float4 sz = float4(UNITY_MATRIX_M._m02, UNITY_MATRIX_M._m12, UNITY_MATRIX_M._m22, 0);
float scaleX = length(sx);
float scaleY = length(sy);
float scaleZ = length(sz);
scaleMatrix[0] = float4(scaleX, 0, 0, 0);
scaleMatrix[1] = float4(0, scaleY, 0, 0);
scaleMatrix[2] = float4(0, 0, scaleZ, 0);
scaleMatrix[3] = float4(0, 0, 0, 1);
// 월드 공간 회전 행렬 계산
float4x4 rotationMatrix;
float isOnlyYaw = _BILLBOARD_ONLYYAW;
#if _BILLBOARD_MODE == _BILLBOARD_MODE_OFF
// Model Matrix에서 회전 행렬 추출
rotationMatrix[0] = float4(UNITY_MATRIX_M._m00 / scaleX, UNITY_MATRIX_M._m01 / scaleY, UNITY_MATRIX_M._m02 / scaleZ, 0);
rotationMatrix[1] = float4(UNITY_MATRIX_M._m10 / scaleX, UNITY_MATRIX_M._m11 / scaleY, UNITY_MATRIX_M._m12 / scaleZ, 0);
rotationMatrix[2] = float4(UNITY_MATRIX_M._m20 / scaleX, UNITY_MATRIX_M._m21 / scaleY, UNITY_MATRIX_M._m22 / scaleZ, 0);
rotationMatrix[3] = float4(0, 0, 0, 1);
#elif _BILLBOARD_MODE == _BILLBOARD_MODE_POS // 카메라 위치 기준 빌보드
// 월드 포지션 기준 카메라 방향 벡터 계산
float3 pivotPosOS = float3(0, 0, 0);
float4 pivotPosWS = mul(UNITY_MATRIX_M, float4(pivotPosOS.xyz, 1));
float4 cameraPosWS = float4(_WorldSpaceCameraPos.xyz, 1);
float3 cameraLookDir = normalize(pivotPosWS.xyz - cameraPosWS.xyz);
if(isOnlyYaw==1.0f) cameraLookDir.y = 0.0f; // Yaw축 빌보드
// 카메라 방향 벡터를 회전 행렬로 계산
float3 upVector = float3(0, 1, 0);
float3 rotM2 = cameraLookDir;
float3 rotM0 = normalize(cross(upVector, rotM2));
float3 rotM1 = cross(rotM2, rotM0);
float4x4 cameraRotMatrix;
cameraRotMatrix[0] = float4(rotM0.x, rotM1.x, rotM2.x, 0);
cameraRotMatrix[1] = float4(rotM0.y, rotM1.y, rotM2.y, 0);
cameraRotMatrix[2] = float4(rotM0.z, rotM1.z, rotM2.z, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
rotationMatrix = cameraRotMatrix;
#elif _BILLBOARD_MODE == _BILLBOARD_MODE_ROT // 카메라 방향(Rotation) 기준 빌보드
float4x4 cameraRotMatrix;
// View 메트릭스(카메라 회전 행렬)를 회전 행렬로 사용
cameraRotMatrix[0] = float4(UNITY_MATRIX_V._m00, UNITY_MATRIX_V._m01, UNITY_MATRIX_V._m02, 0);
cameraRotMatrix[1] = float4(UNITY_MATRIX_V._m10, UNITY_MATRIX_V._m11, UNITY_MATRIX_V._m12, 0);
cameraRotMatrix[2] = float4(UNITY_MATRIX_V._m20, UNITY_MATRIX_V._m21, UNITY_MATRIX_V._m22, 0);
cameraRotMatrix[3] = float4(0, 0, 0, 1);
if(isOnlyYaw==1.0f) cameraRotMatrix[1] = float4(0, 1, 0, 0); // Yaw축 빌보드
rotationMatrix = transpose(cameraRotMatrix); // View 공간을 월드 공간 기준으로 계산하기 위해 전치행렬로 변환하여 역행렬 계산하게 세팅.
#endif
float4x4 moveMatrix;
// 월드 공간 위치 행렬 계산
moveMatrix[0] = float4(1, 0, 0, UNITY_MATRIX_M._m03);
moveMatrix[1] = float4(0, 1, 0, UNITY_MATRIX_M._m13);
moveMatrix[2] = float4(0, 0, 1, UNITY_MATRIX_M._m23);
moveMatrix[3] = float4(0, 0, 0, UNITY_MATRIX_M._m33);
float4x4 modelMatrix = mul(mul(moveMatrix, rotationMatrix), scaleMatrix);
float4 positionWS = mul(modelMatrix, float4(positionOS.xyz, 1));
float4 positionVS = mul(UNITY_MATRIX_V, positionWS);
float4 positionCS = mul(UNITY_MATRIX_P, positionVS);
o.positionCS = positionCS;
o.uv = v.uv;
return o;
}
float4 frag(v2f i) : SV_Target
{
float2 mainTexUV = i.uv.xy * _MainTex_ST.xy + _MainTex_ST.zw;
float4 col = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, mainTexUV);
return col * _Color;
}
ENDHLSL
}
}
}
참고 자료
- Model 행렬에서 SRT행렬 추출 - https://www.sysnet.pe.kr/2/0/11637
- SRT행렬에서 Transform값 추출 - https://www.sysnet.pe.kr/2/0/11640
- 빌보드 구현 - https://www.sysnet.pe.kr/2/0/11641
- LookAt 행렬1 - https://forum.unity.com/threads/problem-with-lookat-vertex-shader-model-space.384903/
- LookAt 행렬2 - https://shahriyarshahrabi.medium.com/look-at-transformation-matrix-in-vertex-shader-81dab5f4fc4
- 역행렬 변환 원리 - https://forum.unity.com/threads/mul-function-and-matrices.445266/
https://shahriyarshahrabi.medium.com/look-at-transformation-matrix-in-vertex-shader-81dab5f4fc4
'Unity > Shader & VFX' 카테고리의 다른 글
[URP] HLSL SoftParticles Shader (0) | 2023.03.07 |
---|---|
자동 UV타일링 쉐이더 (1) | 2022.11.11 |
털(Fur) 쉐이더 R&D - 로스트아크에서 털 표현하는 방법 (0) | 2022.11.01 |
URP 반투명 굴절 구현(Transparent Grabpass) (3) | 2022.03.22 |
[Tutorial] Hexagon Barrier (0) | 2020.02.14 |
WRITTEN BY
- CatDarkGame
Technical Artist dhwlgn12@gmail.com