Notice
Recent Posts
Recent Comments
Link
반응형
«   2026/01   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

RenderLog

Forward Plus Rendering 본문

Graphics/Graphics

Forward Plus Rendering

scahp 2020. 5. 26. 19:29

 

Forward Plus Rendering

최초작성 : 2020-05-26

마지막수정 : 2020-05-27

최재호

목표

Forward+ rendering 을 이해하고 구현해봅시다.

 

내용

포워드 플러스 렌더링은 Tiled forward rendering과 동일한 방식입니다. 기본적인 아이디어는 아래와 같습니다.

이전에 소개한 Tile forward rendering을 보고싶다면 여기를 눌러주세요.

 

1. 화면을 정사각 크기의 타일들을 나눕니다.

2. 타일과 겹치는 라이트들을 타일에 기록해둡니다.

3. 라이팅 패스에서 현재 픽셀의 위치를 기반하여 어느 타일에 위치한지 알아냅니다. 그리고 현재 타일에 겹쳐지는 라이트만을 사용하여 라이팅 처리를 진행합니다.

 

위의 구현에서 기대하는 점은 픽셀당 연산처리 해야할 라이트의 개수를 줄여주어 픽셀쉐이더에서 라이팅 연산을 줄여주는 것입니다.

 

Forward+ 의 기본 적인 패스는 다음과 같습니다.
1. Depth-only pass
2. Light culling pass
3. Light accumulate pass

 

Light Accumulate Pass(라이팅 패스)에서 Depth-only pass에서 사용한 Depth buffer를 사용할 수 있다면, Depth Equal인 경우만 렌더링하는 경우 더 좋은 성능을 기대할 수 있음. 왜냐하면 Early-Z 가 사용가능한 환경이라면 픽셀쉐이딩이 일어나기전에 discard 될 수 있기 때문입니다.

 

위에서 설명했듯 Forward+는 Tiled forward rendering과 같은 방식입니다. Tiled forward rendering에서는 CPU에서 Light들을 정렬했습니다. 이번에는 GPU에서 처리하는 방식을 소개하려 합니다.

그리고 추가적으로 2.5D 컬링과 쉐도우를 위한 레이트레이싱 등의 추가 기능이 소개되어있지만 여기서는 기본 기능을 이해하고 구현해보는데 집중 하도록 하겠습니다.

 

GPU에서 구현은 Compute Shader를 사용합니다. 그래서 Compute Shader의 사용법을 알고 있어야 상세 구현을 읽기가 편합니다. 간단히 Compute Shader를 설명해드리면 아래와 같습니다. DirectX 용 Compute Shader 설명이라 용어가 조금 다르지만 이해하는데 무리는 없습니다.

Compute Shader를 실행할때는 Dispatch(X, Y, Z) 함수를 호출합니다. 이 때 SV_Group 에 해당하는 부분이 X, Y, Z가 됩니다. 각각의 X, Y, Z는 이름이 Group인것 처럼 내부에 Thread들을 가지고 있습니다. 이 스레드도 역시 3차원으로 x, y, z 형태로 되어있습니다. 이 글에서 Group의 경우 Tile, Group 내의 Thread의 경우 렌더타겟의 픽셀에 매핑시켜서 사용하게 됩니다.

 

DirectXOpenGL
SV_GroupIDgl_WorkGroupID
없음gl_NumWorkGroups
SV_GroupThreadIDgl_LocalInvocationID
SV_GroupIndexgl_LocalInvocationIndex
SV_DispatchThreadIDgl_GlobalInvocationID

표1. DirectX의 Compute Shader 변수와 대응되는 OpenGL의 Compute Shader 변수들

 

 

그림1. Compute Shader 구조, 출처 : https://docs.microsoft.com/en-us/windows/win32/direct3dhlsl/sv-groupid

 

 

상세 구현 설명

1. Depth-only pass

단순히 프리미티브를 depth buffer에 렌더링 합니다.


2 Light culling pass

화면을 가로/세로를 타일 크기로 나눠 줍니다. (여기서 나눠진 타일의 수를 WorkGroup.xy로 사용합니다.)

각 WorkGroup은 1개의 타일을 의미하며, 타일내에서는 총 (타일크기x타일크기) 개수 만큼의 픽셀이 있을 것입니다.

각 WorkGroup이 가지고 있는 픽셀 개수만큼의 스레드를 만들어서 라이트 중 현재 타일과 겹쳐진 라이트들을 골라냅니다. 총 라이트의 개수가 1000개고 타일안에 100개의 픽셀이 있다고 합시다. 이 경우 각 스레드는 라이트를 10개씩 할당 받으며 이 라이트가 현재 타일과 겹쳐지는 지 확인합니다. 스레드당 할당받는 라이트 수는 (전체 라이트 수 / 타일내의 픽셀 수) 입니다.

 

그렇다면 어떻게 라이트가 타일 내에 있는지 알 수 있는지 알아봅시다.

먼저 타일내의 모든 픽셀에 대해서 Depth 값의 최대/최소 값을 얻어옵니다. 현재 타일내에 픽셀과 일치하는 개수의 스레드가 생성됩니다. 그래서 각 스레드는 화면상에 자신의 위치에 있는 Depth 값을 얻어옵니다. 그리고 Atomic 오퍼레이션을 사용하여 이 값을 타일내의 다른 스레드와 동기화 합니다.

 

먼저 Min/Max Depth를 초기화 하고, Depth의 Min/Max를 얻어냅니다.

// Shared 변수값은 WorkGroup 내에서 서로 공유 됩니다.
shared uint minDepth;
shared uint maxDepth;
shared uint numOfVisibleLight;

void main()
{
	ivec2 pixelLocation = ivec2(gl_GlobalInvocationID.xy);
	ivec2 tileID = ivec2(gl_WorkGroupID.xy);
	ivec2 numOfTiles = ivec2(gl_NumWorkGroups.xy);
	uint tileIndex = tileID.y * numOfTiles.x + tileID.x;

	// 타일내(WorkGroup)의 첫번째 스레드가 변수들을 초기화 합니다.
	if (gl_LocalInvocationIndex == 0)
	{
		minDepth = 0xFFFFFFFF;
		maxDepth = 0;
		numOfVisibleLight = 0;
	}
	
    // 타일내(WorkGroup)의 모든 스레드가 barrier()에서 기다렸다가 동시에 다시 시작됩니다.
	barrier();

	// 현재 픽셀 위치의 Depth 값을 Depth Texture에서 얻어옵니다.
	float localMaxDepth, localMinDepth;
	vec2 pixelLocationInUV = vec2(pixelLocation) / ScreenSize;
	float depth = texture(tex_object, pixelLocationInUV).r;

	// Atomic operation을 통해 Min/Max 값을 누적합니다.
	uint depthInt = floatBitsToUint(depth);
	atomicMin(minDepth, depthInt);
	atomicMax(maxDepth, depthInt);

	barrier();
    ...
}

 

다음으로 화면의 타일 위, 아래, 좌, 우에 해당하는 Plane을 만들어 줍니다. 이 Plane과 Point Light와의 겹침 체크를 통해서 타일에 포함될지 여부를 판정할 것입니다.

vec3 ProjToView(float x, float y, float z)
{
	float X = (2.0f * x) - 1.0f;
	float Y = (2.0f * y) - 1.0f;
	float Z = (2.0f * z) - 1.0f;
	vec4 result = InverseProjection * vec4(X, Y, Z, 1);
	result /= result.w;
	return result.xyz;
};

vec4 CreatePlaneEquation(vec3 a, vec3 b, vec3 c)
{
	vec3 normal = normalize(cross(b - a, c - a));
	float d = -dot(normal, a);
	return vec4(normal, d);
}

shared vec4 frustumPlanes[6];

void main()
{
	vec3 v[4];
    // 현재 WorkGroup의 첫번째 스레드만 Tile의 Plane들을 만들어 준다.
	if (gl_LocalInvocationIndex == 0)
	{
    	// View space 기준으로 Z 값을 얻어냄
		tileMinDepth = uintBitsToFloat(minDepth);
		tileMaxDepth = uintBitsToFloat(maxDepth);
		tileMinDepth = -ProjToView(0.0, 0.0, tileMinDepth).z;
		tileMaxDepth = -ProjToView(0.0, 0.0, tileMaxDepth).z;

		// tileID.xy는 WorkGroup.xy
		vec2 temp = vec2(gl_NumWorkGroups);
		v[0] = ProjToView(tileID.x / temp.x,			tileID.y / temp.y,			1.0f);
		v[1] = ProjToView((tileID.x + 1) / temp.x,		tileID.y / temp.y,			1.0f);
		v[2] = ProjToView((tileID.x + 1) / temp.x,		(tileID.y + 1) / temp.y,	1.0f);
		v[3] = ProjToView(tileID.x / temp.x,			(tileID.y + 1) / temp.y,	1.0f);

		// 원점, 2개의 점을 사용하여 Plane을 생성함.
		vec3 o = vec3(0.0);
		for(int i=0;i<4;++i)
			frustumPlanes[i] = CreatePlaneEquation(o, v[i], v[(i + 1) & 3]);
	}

	barrier();
    ...
}

 

이제 모든 라이트에 대해서 현재 타일에 겹침여부를 판정합니다. 타일내의 스레드들이 라이트들을 나눠가져가서 연산을 진행합니다.

float DistanceBetweenPlanePoint(vec4 plane, vec3 pos, float radius)
{
	return dot(plane.xyz, pos) + plane.w - radius;
}

void main()
{
	uint threadCount = TILE_SIZE * TILE_SIZE;

	// 스레드들이 담당할 라이트들을 분배하여 처리함
	uint passCount = (LightCount + threadCount - 1) / threadCount;
	for (uint i = 0; i < passCount; i++)
	{
		uint lightIndex = i * threadCount + gl_LocalInvocationIndex;
		if (lightIndex >= LightCount) {
			break;
		}

		// 라이트의 위치를 View space 변환
		vec3 LPos = lightBuffer.LightData[lightIndex].Pos;
		LPos = MulView(LPos);
		
		float LRadius = lightBuffer.LightData[lightIndex].Radius;
		float a0 = DistanceBetweenPlanePoint(frustumPlanes[0], LPos, LRadius);	// bottom
		float a1 = DistanceBetweenPlanePoint(frustumPlanes[1], LPos, LRadius);	// right
		float a2 = DistanceBetweenPlanePoint(frustumPlanes[2], LPos, LRadius);	// top
		float a3 = DistanceBetweenPlanePoint(frustumPlanes[3], LPos, LRadius);	// left

		// OpenGL의 경우 -Z 방향이 앞쪽이므로 보정해줌
		float tempZ = -LPos.z;
		bool isInsideZ = (((tileMinDepth - LRadius) < tempZ) && (tempZ < (tileMaxDepth + LRadius)));

		bool isInside = (a0 < 0 && a1 < 0 && a2 < 0 && a3 < 0);
		if (isInside && isInsideZ)
		{
			uint offset = atomicAdd(numOfVisibleLight, 1);
			visibleLightIndices[offset] = int(lightIndex);
		}
	}

	barrier();
}

 

 

각 WorkGroup(타일)의 작업이 모두 마쳐졌으면, 각 타일별 사용하는 라이트들의 인덱스를 전역 라이트 인덱스 테이블에 저장합니다.

	// 각 타일의 첫번째 쓰레드만 작동합니다.
	if (gl_LocalInvocationIndex == 0)
	{
    	// 현재 타일당 라이트를 최대 1024개 담을 수 있게 설정되어있음
		uint offset = tileIndex * 1024;		// 전역 버퍼의 라이트 인덱스 시작 오프셋
		for (int i = 0; i < numOfVisibleLight; ++i)
		{
			visibleLightIndicesBuffer.data[offset + i].index = visibleLightIndices[i];
		}

		// 현재 타일의 라이트가 1024 보다 작다면 라이트가 없다는 뜻으로 마지막 라이트 인덱스 다음에 -1을 채움
		if (numOfVisibleLight != 1024)
			visibleLightIndicesBuffer.data[offset + numOfVisibleLight].index = -1;
	}

 

메모리 량을 고민해봅시다.

만약 타일의 개수가 1280 x 768 이라면 타일 수는 40 x 24 개입니다. 그럼 전역 라이트를 저장하는 버퍼의 메모리는 40 x 24 x 1024 x sizeof(int) 입니다. 그럼 총 3,932,160 바이트 이므로 3MB 정도 됩니다. 그리고 전역 라이트 인덱스 리스트는 SSBO(Shader Storage Object)를 사용하여 쉐이더에 전달합니다. DirectX라면 UAV에 해당합니다.


3. Light Accumulate pass

위에서 만든 라이트 인덱스 리스트를 라이팅 패스에 보냅니다. 라이팅 패스에서는 현재 그리는 픽셀이 어떤 타일에 속해있는지 알아냅니다. 그리고 해당 타일에 겹치는 모든 라이트 리스트에 대해서 라이트 연산을 진행합니다.

#version 430 core

precision mediump float;

#define TILE_SIZE 32

struct PointLight 
{
	vec3 Pos;
	float Radius;
	vec4 Color;
};

struct VisibleIndex 
{
	int index;
};

layout(std430, binding = 0) buffer LightBuffer
{
	PointLight LightData[];
} lightBuffer;

layout(std430, binding = 1) buffer VisibleLightIndicesBuffer
{
	VisibleIndex data[];
} visibleLightIndicesBuffer;

uniform int numOfTilesX;

in vec3 Pos_;
in vec4 Color_;

out vec4 color;

void main()
{
	// 현재 처리중인 픽셀의 위치를 얻어옴
	ivec2 pixelLocation = ivec2(gl_FragCoord.xy);
    
    // 현재 픽셀이 위치하는 타일의 위치를 계산합니다.
	ivec2 tileID = pixelLocation / ivec2(TILE_SIZE, TILE_SIZE);
	uint tileIndex = tileID.y * numOfTilesX + tileID.x;

	color = vec4(Color_) * vec4(0.2, 0.2, 0.2, 1.0);

	// 타일당 1024개의 라이트가 포함되어있으며, 1024개 까지 처리했거나 
    // 혹은 -1을 만나면 더이상 처리할 라이트가 없다는 의미 입니다.
	uint offset = tileIndex * 1024;
	for (uint i = 0; i < 1024 && visibleLightIndicesBuffer.data[offset + i].index != -1; ++i)
	{
    	// 전역 라이트 인덱스 리스트에서 라이트 인덱스를 얻어 해당 라이트를 처리함.
		uint lightIndex = visibleLightIndicesBuffer.data[offset + i].index;
		PointLight light = lightBuffer.LightData[lightIndex];

		float dist = distance(light.Pos, Pos_);
		if (dist < light.Radius)
		{
			float distSq = dist * dist;
			const float LightIntensity = 100.0;
			color += (light.Color * (LightIntensity / distSq));
		}
	}
}

 

확장

2.5D 컬링 방식을 사용하여, Depth 를 여러 단계로 쪼개서 사용할 수 있는 기능 또한 제시하였습니다.
Frustum의 z 범위를 cell 단위로 나눕니다. 그리고 각 라이트는 int32 값을 가지며, int32의 각 비트는 Frustum에서 어떤 cell과 겹치는를 마킹해두는데 사용합니다. LightAccumulate 에서 현재 렌더링중인 픽셀이 어느 cell에 포함되는지 알게 되면, 해당 cell 에 겹치는 light 만 적용할 수 있을 것입니다.

여기서도 쉐도우에 대해서는 쉐도우 맵을 사용하기에 너무나 부하가 높기 때문에 레이트 레이싱과 ForwardPlus 렌더링을 같이 사용하는 것을 추천합니다.

 

구현결과

 

그림2. 최종결과
그림3. 최종결과2

 

 

구현코드

https://github.com/scahp/Shadows/tree/ForwardPlus

 

레퍼런스

GPU Pro4, Forward+: A Step Toward Film-Style Shading in Real Time

https://github.com/bcrusco/Forward-Plus-Renderer

 

 

반응형

'Graphics > Graphics' 카테고리의 다른 글

Cascade Shadow Map  (2) 2020.07.10
VolumeLight  (0) 2020.06.24
PCSS(Percentage-Closer Soft Shadow)  (0) 2020.05.22
Tiled Forward Rendering  (0) 2020.05.12
Shadow Volume (Stencil Shadow) - 구현 (2/2)  (0) 2020.05.05