Billboard는 3D 공간에 존재하지만 항상 카메라를 바라보는 특성을 가진 2D 사각형(Quad) 형태의 오브젝트야. 마치 게임 속의 NPC 이름표, 체력바, UI 표지판, 파티클(비, 눈, 연기 등)이 항상 정면을 유지해야 할 때 쓰이지.
3D 모델링보다 훨씬 가볍고, 오브젝트가 많아질 때 성능 면에서 유리해. 특히 반복되는 요소들(풀, 나무, 파티클 등)엔 필수.
먼저 하나의 오브젝트가 항상 카메라를 바라보도록 회전(Rotation)을 조정해주는 컴포넌트 스크립트를 만들어야 해.
class BillboardTest : public MonoBehaviour
{
public:
virtual void Update();
};
이 스크립트는 MonoBehaviour를 상속받아 Update()에서 매 프레임마다 자신의 회전값을 수정함으로써 카메라를 바라보게 만들어.
void BillboardTest::Update()
{
auto go = GetGameObject();
Vec3 up = Vec3(0, 1, 0); // 월드 상단 방향
Vec3 cameraPos = CUR_SCENE->GetMainCamera()->GetTransform()->GetPosition();
Vec3 myPos = GetTransform()->GetPosition();
Vec3 forward = cameraPos - myPos;
forward.Normalize();
Matrix lookMatrix = Matrix::CreateWorld(myPos, forward, up); // 내 위치 기준으로 카메라를 바라보는 방향을 가진 행렬
Vec3 S, T;
Quaternion R;
lookMatrix.Decompose(S, R, T); // 행렬을 Scale, Rotation(Quaternion), Translation으로 분해
Vec3 rot = Transform::ToEulerAngles(R); // Quaternion → EulerAngles(회전 벡터)
GetTransform()->SetRotation(rot); // 최종적으로 적용
}
SetRotation은 Vec3 타입의 오일러 각도를 받기 때문이야. Quaternion은 회전을 정확하게 표현하기엔 좋지만, 일반적인 3D 게임 엔진에서 사용할 때는 오일러 각도로 변환해서 사용해.
500개 오브젝트에 각각 BillboardTest를 붙이면 성능상 비효율적이고 관리도 어렵다.
정점을 늘리고, 쉐이더에서 좌표 연산을 수행하는 방식으로 변경한다.
struct VertexBillboard
{
Vec3 position; // 시작 위치
Vec2 uv; // 텍스처 좌표
Vec2 scale; // 크기
};
하나의 Billboard는 정점 4개(사각형)를 사용하므로, 이 구조체는 사각형 하나에 필요한 정보를 담는다.
const int32 vertexCount = MAX_BILLBOARD_COUNT * 4;
const int32 indexCount = MAX_BILLBOARD_COUNT * 6;
_vertices.resize(vertexCount);
_indices.resize(indexCount);
for (int32 i = 0; i < MAX_BILLBOARD_COUNT; i++)
{
_indices[i * 6 + 0] = i * 4 + 0;
_indices[i * 6 + 1] = i * 4 + 1;
_indices[i * 6 + 2] = i * 4 + 2;
_indices[i * 6 + 3] = i * 4 + 2;
_indices[i * 6 + 4] = i * 4 + 1;
_indices[i * 6 + 5] = i * 4 + 3;
}
void Billboard::Add(Vec3 position, Vec2 scale)
{
// 위치와 크기는 같고, 쉐이더에서 좌표 계산함
for (int i = 0; i < 4; ++i)
{
_vertices[_drawCount * 4 + i].position = position;
_vertices[_drawCount * 4 + i].scale = scale;
}
_vertices[_drawCount * 4 + 0].uv = Vec2(0, 1);
_vertices[_drawCount * 4 + 1].uv = Vec2(0, 0);
_vertices[_drawCount * 4 + 2].uv = Vec2(1, 1);
_vertices[_drawCount * 4 + 3].uv = Vec2(1, 0);
_drawCount++;
}
V_OUT VS(VertexInput input)
{
V_OUT output;
float4 position = mul(input.position, W);
float3 up = float3(0, 1, 0);
float3 forward = position.xyz - CameraPosition(); // 카메라 기준
float3 right = normalize(cross(up, forward));
position.xyz += (input.uv.x - 0.5f) * right * input.scale.x;
position.xyz += (1.0f - input.uv.y - 0.5f) * up * input.scale.y;
output.position = mul(mul(position, V), P);
output.uv = input.uv;
return output;
}
float4 PS(V_OUT input) : SV_Target
{
float4 diffuse = DiffuseMap.Sample(LinearSampler, input.uv);
if (diffuse.a < 0.3f)
discard;
return diffuse;
}
알파 블렌딩을 적용해서 텍스처의 투명도를 활용할 수 있게 해준다.
Billboard를 응용해 눈처럼 떨어지는 파티클 효과를 만든다.
struct SnowBillboardDesc
{
Color color;
Vec3 velocity; // 떨어지는 속도
float drawDistance;
Vec3 origin;
float turbulence; // 흔들림
Vec3 extent;
float time; // 경과 시간
};
input.position.y = Origin.y + Extent.y - (input.position.y - Velocity.y * Time * 100) % Extent.y;
input.position.x += cos(Time - input.random.x) * Turbulence;
input.position.z += cos(Time - input.random.y) * Turbulence;
시간 기반으로 눈이 떨어지고, X/Z축으로 흔들림 효과가 들어간다.
float4 view = mul(position, V);
output.alpha = saturate(1 - view.z / DrawDistance) * 0.8f;
diffuse.rgb = Color.rgb * input.alpha * 2.0f;
diffuse.a = diffuse.a * input.alpha * 1.5f;
카메라에서 멀어질수록 더 은은하게 보이도록 만들어주는 표현 방식이다.