Terrain
물방울 책에 있는 Terrain 예제는 엄청난 기술들의 집합이다.
이렇게 큰 terrain을 관리할 때, 여기에 사용된 주된 기술은 다음과 같다.
- 지형의 높이를 관리하기 위해 Height map 이라는 텍스처를 이용한다. 이때 이 텍스처는 일정 텀마다의 높이를 저장하고 있다. (HeightMap을 통한 지형 높이 지정)
- Smooth하게 높이를 만들기 위해 쉐이더에서 위치를 Height map을 이용해 지정하기 직전 주변 픽셀 8개를 분석해 평균 값으로 높이를 지정한다.(주변 높이와 평균 값을 맞춰 Smooth)
- 두번째 사진과 같이 Tessellation을 이용해 LOD를 적용했다. 이때 사용된 것은 Terrain이 워낙 넓기 때문에 이를 몇개의 그리드로 잘라서 해당 그리드가 카메라와 멀어지면 삼각형의 개수를 줄이는 방식이다.
- Frustum Culling 기법을 GPU에서 적용해 절두체의 6개 면안에 있는지를 확인해 효율을 늘리게 적용되었다.
- 지금 맵을 보면 돌도 있고 풀도 있고 길도 있다. 이것을 적용하기 위해 Terrain size의 uv 좌표에 맵핑되게 텍스처를 구성하면 텍스처의 사이즈는 어마무시할 것이다. 그렇기 때문에 돌이 적용되는 텍스처, 풀이 적용되는 텍스처 등으로 나눠 관리하고 이를 Blend 텍스처로 합쳐서 관리한다. 가령 blend 텍스처에는 돌 30% 풀 50% 흙 20%... 으로 저장되어 있는것이다.
Particle
파티클은 수많은 입자(사각형)을 만들어서 이를 적절한 블렌딩을 통해서 나타내는 것이다.
그렇다면 이 수 많은 파티클들을 관리하는 주체는 누구일까?
CPU에서 관리하면 당연히 훨씬 쉽고 간편하게 할 수 있지만 문제는 이 수많은 입자들을 계속 복사하고 참조하게되어 문제가 발생할 것이다.
그래서 GPU단에서 관리를 한다.
Geometry Shader 단계에서 정점을 늘리는 건 맞지만 파티클이라고 하는 것은 1회성이 아니다.
즉, 생명주기가 있다는 말이고 GPU에서 생성된 파티클을 다시 재활용해 생명주기를 관리해야한다. 렌더링 파이프라인중 하나인 Stream-output 단계를 이용한다.
[maxvertexcount(2)]
void StreamOutGS(point Particle gin[1],
inout PointStream<Particle> ptStream)
{
gin[0].Age += gTimeStep;
if (gin[0].Type == PT_EMITTER)
{
// time to emit a new particle?
if (gin[0].Age > 0.005f)
{
float3 vRandom = RandUnitVec3(0.0f);
vRandom.x *= 0.5f;
vRandom.z *= 0.5f;
Particle p;
p.InitialPosW = gEmitPosW.xyz;
p.InitialVelW = 4.0f * vRandom;
p.SizeW = float2(3.0f, 3.0f);
p.Age = 0.0f;
p.Type = PT_FLARE;
ptStream.Append(p);
// reset the time to emit
gin[0].Age = 0.0f;
}
// always keep emitters
ptStream.Append(gin[0]);
}
else
{
// Specify conditions to keep particle; this may vary from system to system.
if (gin[0].Age <= 1.0f)
ptStream.Append(gin[0]);
}
}
GeometryShader gsStreamOut = ConstructGSWithSO(
CompileShader(gs_5_0, StreamOutGS()),
"POSITION.xyz; VELOCITY.xyz; SIZE.xy; AGE.x; TYPE.x");
코드에서 중요한 부분은 stream에 append하는 부분이다. append 함으로써 다음 프레임에 그대로 활용이 가능한 것이다.
void ParticleSystem::BuildVB(ComPtr<ID3D11Device> device)
{
//
// Create the buffer to kick-off the particle system.
//
D3D11_BUFFER_DESC vbd;
vbd.Usage = D3D11_USAGE_DEFAULT;
vbd.ByteWidth = sizeof(Vertex::Particle) * 1;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER;
vbd.CPUAccessFlags = 0;
vbd.MiscFlags = 0;
vbd.StructureByteStride = 0;
// The initial particle emitter has type 0 and age 0. The rest
// of the particle attributes do not apply to an emitter.
Vertex::Particle p;
ZeroMemory(&p, sizeof(Vertex::Particle));
p.Age = 0.0f;
p.Type = 0;
D3D11_SUBRESOURCE_DATA vinitData;
vinitData.pSysMem = &p;
HR(device->CreateBuffer(&vbd, &vinitData, _initVB.GetAddressOf()));
//
// Create the ping-pong buffers for stream-out and drawing.
//
vbd.ByteWidth = sizeof(Vertex::Particle) * _maxParticles;
vbd.BindFlags = D3D11_BIND_VERTEX_BUFFER | D3D11_BIND_STREAM_OUTPUT;
HR(device->CreateBuffer(&vbd, 0, _drawVB.GetAddressOf()));
HR(device->CreateBuffer(&vbd, 0, _streamOutVB.GetAddressOf()));
}
보면 _drawVB 라는 버퍼를 만들긴 하지만 안에 내용물은 하나도 채워져 있지 않고 바로 GPU에게 넘겨주게된다. 이 것이 GPU에서 생성된 파티클들을 담을 공간인 것이다.
그전까지는 CPU에서 버퍼를 만들고 이를 채워서 GPU에 넘겨주면 GPU에서 받은 값을 바탕으로 원하는 동작을 했다면 이번에는 CPU에서 버퍼만 만들고 GPU에서 받은 버퍼안에 내용물을 기록하면서 계속 사용하는 것이다.
유심히 보면 정점이 하나인 _initVB가 있다. 이 친구의 역할은 맨 처음 파티클을 생성할때는 GPU에 어떠한 정보도 존재하지 않기 때문에 이를 초기화해 정점을 만들어 줄 친구가 필요하다.
즉 만약 불꽃을 피워내고 싶은데 아무것도 없는 상태면 불꽃을 먼저 만들어 내고 그 뒤로부터는 불꽃이 하늘로 서서히 움직에게 하는 것이다.
결론적으로 파티클 시스템은 Geometry Shader 단계에서 정점을 늘려서 관리하는 건 맞지만 Stream Ouput 단계를 통해 늘린 정점을 GPU단에서 관리할 수 있게 하는 것이다.
Shadow
그림자를 생성하는 전략을 먼저 알아보자.
그림자가 생긴 이유는 뭘까? 태양이 빛을 쏘고 있기 때문에 생긴다. 즉 바닥에 빛이 물체에 가려져 오지 못하기 때문에 주변보다 조금 어두운것 그것이 그림자이다.
그렇다는 말은 그림자를 그리기 위해선 앞에 빛을 막는 어떤 물체가 있는지 판별을 해야한다는 말이다.
우리가 어떤 물체를 화면에 그릴때 A가 B보다 뒤에 있다면 A를 그리지 않는다. 이는 Depth stencil 에서 0~1으로 결정된 물체들의 깊이 값을 따져서 어떤 물체가 다른 물체보다 뒤에 있다면 아예 렌더링에서 제외하는 것이다.
즉 물체를 그릴때 깊이 값은 코어한 것이다. 그림자를 그릴때도 매우 중요하다.
그럼 그림자에서 깊이 값은 무엇을 기준으로 해야할까? 카메라? 당연히 광원으로 부터의 깊이 값이다.
생각해보면 카메라가 광원이랑 정확히 동일한 위치와 각도에 있다고 하면 그림자는 그려지지 않을 것이다.
이러한 깊이 값을 계산하기 위해 투 스텝을 해야한다.
첫번째로는 광원이 마치 카메라인듯 광원에서 바라보는 방향과 각도로 깊이 값만 계산해 저장하는 것이다. 카메라가 마치 광원 위치에 있는 것과 동일하게 말이다. 그 깊이값을 Shadow Map에 저장하게 된다.
D3D11_DEPTH_STENCIL_VIEW_DESC dsvDesc;
dsvDesc.Flags = 0;
dsvDesc.Format = DXGI_FORMAT_D24_UNORM_S8_UINT;
dsvDesc.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
dsvDesc.Texture2D.MipSlice = 0;
HR(device->CreateDepthStencilView(depthMap.Get(), &dsvDesc, _depthMapDSV.GetAddressOf()));
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
srvDesc.Format = DXGI_FORMAT_R24_UNORM_X8_TYPELESS;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MipLevels = texDesc.MipLevels;
srvDesc.Texture2D.MostDetailedMip = 0;
HR(device->CreateShaderResourceView(depthMap.Get(), &srvDesc, _depthMapSRV.GetAddressOf()));
이렇게 DepthStencilView를 깊이 값을 저장할 용도로 만들어준다. 그런데 ShaderResourceView도 만드는데 그 이유는 우리가 Shadow map 텍스처를 만들었다면 이것을 넘겨줄 때 텍스처로 넘겨주기 때문이다.
자 이제 광원이 마치 카메라인 듯 조정하고 물체를 똑같이 그려주기만 하면 된다.
XMMATRIX view = XMLoadFloat4x4(&_lightView);
XMMATRIX proj = XMLoadFloat4x4(&_lightProj);
XMMATRIX viewProj = XMMatrixMultiply(view, proj);
Effects::BuildShadowMapFX->SetEyePosW(_camera.GetPosition());
Effects::BuildShadowMapFX->SetViewProj(viewProj);
이렇게 그림자를 생성할 쉐이더에 광원의 view projection을 넣어준다.
그러면 이제 깊이 값이 저장이 되었을 것이다.
이것을 SRV를 통해 이제 건네주고 쉐이더에서 이것을 처리하는 것이 두번째 단계이다.
우리가 저장한 Shadow map은 광원을 기준으로 '가장 앞에 있는 물체의 깊이 값'만 저장되어있다.
이 것을 정확히 이해해야만 한다.
여기서 빨간색 위치의 픽셀을 A라고 하고 그림자의 파란색 위치 픽셀을 B라고 하자.
A나 B를 광원이 바라보는 위치로 좌표를 변형해 Shadow map에서 그 위치를 봐서 깊이 값을 가져와 비교하는 것이다.
즉, 광원에서 View 변환, Projection 변환 을하고 나온 결과 -1 ~ 1 값을 0 ~ 1로 만들어주는 행렬을 곱해 광원에서 바라본 좌표계 로 변환하는 변환행렬을 만들어두고
A나 B의 월드 좌표를 이 변환 행렬에 곱해주게 되면 된다.
XMMATRIX T(
0.5f, 0.0f, 0.0f, 0.0f,
0.0f, -0.5f, 0.0f, 0.0f,
0.0f, 0.0f, 1.0f, 0.0f,
0.5f, 0.5f, 0.0f, 1.0f);
XMMATRIX S = V * P * T;
::XMStoreFloat4x4(&_lightView, V);
::XMStoreFloat4x4(&_lightProj, P);
::XMStoreFloat4x4(&_shadowTransform, S);
_shadowTransform이 바로 그 변환 행렬인 것이다.
VertexOut VS(VertexIn vin)
{
VertexOut vout;
// Transform to world space space.
vout.PosW = mul(float4(vin.PosL, 1.0f), gWorld).xyz;
vout.NormalW = mul(vin.NormalL, (float3x3)gWorldInvTranspose);
// Transform to homogeneous clip space.
vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);
// Output vertex attributes for interpolation across triangle.
vout.Tex = mul(float4(vin.Tex, 0.0f, 1.0f), gTexTransform).xy;
// Generate projective tex-coords to project shadow map onto scene.
vout.ShadowPosH = mul(float4(vin.PosL, 1.0f), gShadowTransform);
return vout;
}
PS 단계에서 이렇게 쉐도우를 추출해서 Diffuse나 specular에 적용하면 된다.
shadow[0] = CalcShadowFactor(samShadow, gShadowMap, pin.ShadowPosH);
// Sum the light contribution from each light source.
[unroll]
for (int i = 0; i < gLightCount; ++i)
{
float4 A, D, S;
ComputeDirectionalLight(gMaterial, gDirLights[i], pin.NormalW, toEye,
A, D, S);
ambient += A;
diffuse += shadow[i] * D;
spec += shadow[i] * S;
}
//---------------------------------------------------------------------------------------
// Performs shadowmap test to determine if a pixel is in shadow.
//---------------------------------------------------------------------------------------
static const float SMAP_SIZE = 2048.0f;
static const float SMAP_DX = 1.0f / SMAP_SIZE;
float CalcShadowFactor(SamplerComparisonState samShadow,
Texture2D shadowMap,
float4 shadowPosH)
{
// Complete projection by doing division by w.
shadowPosH.xyz /= shadowPosH.w;
// Depth in NDC space.
float depth = shadowPosH.z;
// Texel size.
const float dx = SMAP_DX;
//return shadowMap.SampleCmpLevelZero(samShadow, shadowPosH.xy, depth).r;
float percentLit = 0.0f;
const float2 offsets[9] =
{
float2(-dx, -dx), float2(0.0f, -dx), float2(dx, -dx),
float2(-dx, 0.0f), float2(0.0f, 0.0f), float2(dx, 0.0f),
float2(-dx, +dx), float2(0.0f, +dx), float2(dx, +dx)
};
[unroll]
for (int i = 0; i < 9; ++i)
{
percentLit += shadowMap.SampleCmpLevelZero(samShadow,
shadowPosH.xy + offsets[i], depth).r;
}
return percentLit /= 9.0f;
}
B에서의 월드 좌표를 광원 좌표계로 변환하게 될것이다. 이때 Depth 값이 결정이 될것이다. 0 ~ 1의 값으로, 그러면 우리가 작성한 Shadow Map에서 그 좌표를 가져와 비교를 하게 되는 것이다. 그 때 그 값이 더 작다면 그림자로 판단하는 것이다.
숫자로 예시를 들면 이해가 빠르다.
우리가 1번째 단계에서 광원에서 바라보는 깊이 값을 ShadowMap에 저장하게 될텐데, 이때 기둥까지의 Depth를 0.5라고 하자. 기둥의 월드 좌표는 0,0이고 광원에서의 스크린 좌표는 400,400 이라고 하자.
그리고 그림자가 생길 위치의 월드 좌표는 0,1 이라고 하고 그 0.1를 광원에서의 스크린 좌표로 변환 했더니 400,400이 되는 것이다.
이때, 0, 1을 변환하는 과정에서 뷰, 투영, 보간을 하게 되면서 그 좌표의 Depth 값이 정해지는데 그값이 0.7이라면 기존에 작성된 0.5보다 크므로 기둥에 가려졌다고 판단할 수 있다. 이제 그림자를 칠해주면 된다.
Ambient Occlusion
우리가 이전까지는 Ambient라고하면 주변광 이기 때문에 모든 픽셀에 대해서 그냥 같은 값을 다 더해줬다.
하지만 이는 현실세계에서 좀 어색해 보일 수 있다. 왜냐하면 어느 굴곡진 곳이나 움푹 패인곳에는 빛이 덜 들어가 분명 더 어둡기 때문이다.
그래서 빛을 굴곡진 곳등을 차단하는 것이 바로 Ambient Occlusion을 말한다.
Ambient를 일반적인 상수로 해결하는 것이 아닌 차단을 해 좀 더 사실적인 표현을 하는 것이다.
이는 엄청난 노가다 연산을 해야한다.
눈 안에 어떠한 정점을 그린다고 해보면 그 점을 기준으로 구 모형으로 전부 다 레이케스트를 쏴 어느 메쉬에 닿은 개수를 기준으로 어둡게 만드는 것이다.
하나도 안 부딪치면 주변에 가려진게 없다는 의미니까 밝게 적용하고
주변에 뭔가 많이 부딪치면 이 정점은 가려진 곳이니까 어둡게 하는 것이다.
진짜 순수한 노가다이다.
void AmbientOcclusionDemo::BuildVertexAmbientOcclusion(
std::vector<Vertex::AmbientOcclusion>& vertices,
const std::vector<uint32>& indices)
{
uint32 vcount = vertices.size();
uint32 tcount = indices.size() / 3;
std::vector<XMFLOAT3> positions(vcount);
for (uint32 i = 0; i < vcount; ++i)
positions[i] = vertices[i].Pos;
Octree octree;
octree.Build(positions, indices);
// For each vertex, count how many triangles contain the vertex.
std::vector<int32> vertexSharedCount(vcount);
for (uint32 i = 0; i < tcount; ++i)
{
uint32 i0 = indices[i * 3 + 0];
uint32 i1 = indices[i * 3 + 1];
uint32 i2 = indices[i * 3 + 2];
XMVECTOR v0 = ::XMLoadFloat3(&vertices[i0].Pos);
XMVECTOR v1 = ::XMLoadFloat3(&vertices[i1].Pos);
XMVECTOR v2 = ::XMLoadFloat3(&vertices[i2].Pos);
XMVECTOR edge0 = v1 - v0;
XMVECTOR edge1 = v2 - v0;
XMVECTOR normal = ::XMVector3Normalize(::XMVector3Cross(edge0, edge1));
XMVECTOR centroid = (v0 + v1 + v2) / 3.0f;
// Offset to avoid self intersection.
centroid += 0.001f * normal;
const int32 NumSampleRays = 32;
float numUnoccluded = 0;
for (int32 j = 0; j < NumSampleRays; ++j)
{
XMVECTOR randomDir = MathHelper::RandHemisphereUnitVec3(normal);
// TODO: Technically we should not count intersections that are far
// away as occluding the triangle, but this is OK for demo.
if (!octree.RayOctreeIntersect(centroid, randomDir))
{
numUnoccluded++;
}
}
float ambientAccess = numUnoccluded / NumSampleRays;
// Average with vertices that share this face.
vertices[i0].AmbientAccess += ambientAccess;
vertices[i1].AmbientAccess += ambientAccess;
vertices[i2].AmbientAccess += ambientAccess;
vertexSharedCount[i0]++;
vertexSharedCount[i1]++;
vertexSharedCount[i2]++;
}
// Finish average by dividing by the number of samples we added.
for (uint32 i = 0; i < vcount; ++i)
{
vertices[i].AmbientAccess /= vertexSharedCount[i];
}
}
'C++ > DirectX 11' 카테고리의 다른 글
[DirectX] 물방울책 - 주요 내용 (0) | 2024.11.04 |
---|---|
[DirectX] 물방울 책 - Picking, Mapping (1) | 2024.10.29 |
[DirectX] 물방울 책 - Tessellation (3) | 2024.10.28 |
[DirectX] 물방울 책 - Shader (1) | 2024.10.25 |
[DirectX] 물방울 책 - 조명, 텍스처 (0) | 2024.10.24 |