1.18 밉매핑과 이방성 필터링
샘플링 문제는 렌더링의 다양한 측면에서 발생할 수 있습니다. 이전 튜토리얼에서 낮은 화면 해상도와 관련된 샘플링 문제에 대해 논의했습니다. 그러나 이러한 문제는 텍스처 맵 샘플링 중에도 발생할 수 있습니다. 텍스처 튜토리얼에서 `textureSample` 함수를 사용하여 텍스처 맵에서 색상을 샘플링했던 것을 기억하십시오. 텍스처 맵이 카메라에서 멀리 떨어져 있을 때, 화면 평면에서의 투영된 크기가 매우 작아질 수 있습니다. 투영된 크기가 프래그먼트 크기보다 훨씬 작으면 텍스처 샘플링 문제가 발생할 수 있습니다. 이는 다음과 같이 설명할 수 있습니다:
위 이미지에서 우리는 체커보드를 렌더링하려고 합니다. 여기서 큰 사각형은 프래그먼트를 나타냅니다. 우리는 프래그먼트 중심을 텍스처 좌표로 사용하여 체커보드 텍스처에서 샘플링합니다. 이 예에서는 가장 오른쪽에 있는 두 개의 프래그먼트만 검은색이고, 나머지는 흰색입니다.
이 상황은 MSAA 튜토리얼에서 직면했던 래스터화 문제와 유사합니다. 이 모든 프래그먼트는 흰색 체커와 검은색 체커 모두와 겹칩니다. 이상적으로는 멀리서 볼 때 엄격하게 검은색이나 흰색이 아닌 회색으로 보여야 합니다.
프래그먼트당 여러 샘플을 사용하면 렌더링 결과는 향상될 수 있지만, 계산 비용이 많이 듭니다. 동적인 렌더링 결과와 달리 텍스처 맵은 정적입니다. 정적 텍스처 맵의 경우 런타임에 슈퍼샘플링을 할 필요가 없습니다. 대신 이 과정을 오프라인으로 수행하고, 샘플링된 색상을 캐시하며, 런타임에 값을 읽을 수 있습니다.
MSAA 튜토리얼에서는 추가 샘플을 사용하고 평균 색상을 최종 색상으로 계산했습니다. 다른 방법으로는 텍스처 맵을 더 작은 크기로 축소하는 것이 있습니다. 이 과정 또한 픽셀 평균화를 포함하며, 축소된 텍스처 맵은 샘플링된 색상의 캐시 역할을 합니다. 이 방법을 필터링이라고 합니다.
텍스처 맵이 단순히 이미지라고 생각하는 것은 오해입니다. 사실, 텍스처 맵은 여러 레벨을 가질 수 있습니다. 이 튜토리얼에서는 다양한 크기의 이미지 피라미드를 사용하는 샘플링 솔루션인 밉매핑에 대해 논의할 것입니다.
각 레벨은 일반적으로 원본 해상도인 레벨 0부터 시작하여 동일한 이미지를 다른 해상도로 포함합니다. 레벨 1에서는 원본 이미지를 1/4로 줄이며, 전체 이미지가 원본 텍스처 맵의 평균 색상을 나타내는 단일 픽셀로 축소될 때까지 이 과정을 반복합니다. 이를 레벨 오브 디테일(levels of detail)이라고 합니다.
위 이미지에서는 텍스처 맵이 화면 평면에 평행한 상황을 보여주었습니다. 그러나 실제로는 드문 경우입니다. 우리는 종종 텍스처가 적용된 다각형을 비스듬한 각도에서 봅니다. 결과적으로 전체 텍스처 다각형에 대한 단일 이상적인 샘플링 속도는 없습니다. 대신 최적의 샘플링 속도는 프래그먼트 단위로 결정되어야 합니다.
플레이그라운드 실행 - 1_18_1_mipmaps이 개념을 실제 코드로 설명하기 위해 텍스처 매핑을 소개할 때 보았던 동일한 셰이더를 살펴보겠습니다. 하지만 바분(baboon) 텍스처 맵 대신, 문제를 더 명확하게 드러내기 위해 체커보드를 사용할 것입니다.
이 단순한 구현에서는 결과가 명확하게 보입니다. 체커보드의 가까운 쪽에서는 체커보드 패턴을 뚜렷하게 볼 수 있습니다. 그러나 먼 쪽을 바라보면 패턴이 줄무늬로 왜곡되어 더 이상 체커보드처럼 보이지 않습니다. 이러한 왜곡은 텍스처 맵을 충분히 자주 샘플링하지 않기 때문에 발생합니다.
프래그먼트를 샘플링할 때, 계속해서 검은색 영역에 떨어지면 텍스처 맵에서 주로 검은색을 검색하게 됩니다. 결과는 번갈아 나타나는 검은색과 흰색 대신 검은색 줄무늬가 됩니다. 마찬가지로 계속해서 흰색 영역을 샘플링하면 검은색 없이 흰색 줄무늬를 얻게 됩니다. 이러한 샘플링 문제는 먼 쪽이 체커보드보다 얼룩말 줄무늬처럼 보이는 이유를 설명합니다.
밉매핑은 이 문제를 효과적으로 해결합니다. 앞서 언급했듯이, 밉맵은 원본 크기부터 단일 픽셀까지 다양한 해상도의 동일한 텍스처 맵 피라미드입니다. 밉맵을 사용할 때, 먼저 밉맵에서 두 개의 적절한 레벨 오브 디테일을 결정합니다. 지금은 구현 세부 사항을 깊이 다루지 않겠지만, WebGPU가 이를 자동으로 처리한다는 점은 주목할 가치가 있습니다. 이 단계의 목표는 현재 프래그먼트와 가장 유사한 크기의 텍셀을 가진 두 개의 텍스처 맵 레벨을 선택하는 것입니다. 이 프로세스를 수동으로 구현하는 예시는 다음 장에서 살펴보겠습니다.
그런 다음 이 두 텍스처 맵에서 색상 샘플링을 수행하여 두 가지 색상을 얻습니다. 마지막으로 이 두 색상 간에 보간하거나 가중 평균을 계산합니다. 이 계산은 프래그먼트 크기가 선택된 두 텍스처의 텍셀과 얼마나 밀접하게 일치하는지에 기반합니다.
밉매핑이 적용된 결과는 아래에 나와 있습니다. 보시다시피, 텍스처 맵이 시점으로부터 멀리 떨어져 있을 때, 엄격하게 검은색이나 흰색으로 남아있지 않고 점차 회색(검은색과 흰색의 혼합)으로 전환됩니다. 이러한 점진적인 디테일 손실은 멀리 있는 물체를 관찰할 때의 실제 시각적 경험과 일치합니다.
WebGPU에서 밉매핑을 구현하는 추가 단계는 셰이더를 사용하여 밉맵을 수동으로 생성해야 한다는 것입니다. OpenGL과 같은 다른 그래픽스 API에 익숙한 사용자들은 텍스처 맵에 대한 자동 밉맵 생성에 익숙할 수 있습니다. 그러나 WebGPU에서는 이를 달성하기 위해 코드를 작성해야 합니다. 다행히도 이 과정은 너무 복잡하지 않습니다. 필요한 코드 변경 사항을 살펴보겠습니다.
const textureDescriptor = {
size: { width: imgBitmap.width, height: imgBitmap.height },
format: 'rgba8unorm',
mipLevelCount: Math.ceil(Math.log2(Math.max(imgBitmap.width, imgBitmap.height))),
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT
};
const texture = device.createTexture(textureDescriptor);
device.queue.copyExternalImageToTexture({ source: imgBitmap }, { texture }, textureDescriptor.size);
체커보드 텍스처 맵을 생성할 때, 이미지-텍스처 맵 변환을 위한 헬퍼 함수를 사용하지 않는다는 점에 유의하십시오. 대신 밉맵 레벨 수를 수동으로 정의합니다.
l = \lceil \log_2 max(w,h) \rceil
위 방정식으로 밉맵 레벨 수를 계산합니다. 이 접근 방식은 단일 픽셀 크기에 도달할 때까지 각 레벨에서 너비와 높이를 절반으로 줄일 수 있게 합니다.
중요하게도, 텍스처의 사용 플래그에는 `TEXTURE_BINDING`뿐만 아니라 `COPY_DST`와 `RENDER_ATTACHMENT`도 포함됩니다. 이 구성은 밉맵 생성 과정에서 읽기와 쓰기 모두를 위해 동일한 텍스처 맵을 렌더 타겟으로 사용하기 때문에 필요합니다.
이제 셰이더 측 수정 사항을 살펴보겠습니다. 이번에는 두 개의 개별 셰이더를 사용한다는 것을 알 수 있습니다. 하나의 셰이더는 텍스처에 대한 밉맵 생성을 전담하고, 두 번째 셰이더는 실제 렌더링에 사용됩니다. 먼저 밉맵 셰이더를 분석한 다음, 밉맵 생성을 위해 필요한 자바스크립트 변경 사항에 대해 논의하겠습니다.
var pos : array, 4> = array, 4>(
vec2(-1.0, 1.0), vec2(1.0, 1.0),
vec2(-1.0, -1.0), vec2(1.0, -1.0));
struct VertexOutput {
@builtin(position) position : vec4,
@location(0) texCoord : vec2,
};
@vertex
fn vs_main(@builtin(vertex_index) vertexIndex : u32) -> VertexOutput {
var output : VertexOutput;
output.texCoord = pos[vertexIndex] * vec2(0.5, -0.5) + vec2(0.5);
output.position = vec4(pos[vertexIndex], 0.0, 1.0);
return output;
}
@group(0) @binding(0) var imgSampler : sampler;
@group(0) @binding(1) var img : texture_2d;
@fragment
fn fs_main(@location(0) texCoord : vec2) -> @location(0) vec4 {
return textureSample(img, imgSampler, texCoord);
}
이어서, 거리에 따라 밉맵을 샘플링하는 방법을 살펴보겠습니다. 밉맵 셰이더는 비교적 간단합니다. 이전에 보았던 것과 동일한 기술을 사용하고 있습니다. 외부 소스에서 전달하는 대신 정점 데이터를 배열로 정의합니다.
함수 스코프 외부에서 정의된 모든 데이터는 지정된 저장 위치를 가져야 한다는 점에 유의해야 합니다. 이 경우, 위치 버퍼는 private 저장소에 저장됩니다.
정점 셰이더에서는 정점 인덱스를 기반으로 텍스처 좌표와 위치를 파생합니다. 우리의 목표는 전체 캔버스 또는 화면을 덮는 사각형을 생성하여 이 영역에 걸쳐 전체 이미지를 효과적으로 렌더링하는 것입니다.
프래그먼트 셰이더는 간단하며, 제공된 텍스처 좌표를 기반으로 텍스처 맵을 샘플링합니다. 자바스크립트 코드에서는 밉맵을 생성하기 위해 먼저 밉맵 파이프라인을 정의해야 합니다.
이 과정은 비교적 간단하지만, 핵심은 구현에 있습니다. 먼저 체커보드 이미지에서 텍스처 맵을 생성합니다. 그런 다음 동일한 체커보드 텍스처 맵을 사용하되, 다른 레벨을 렌더 타겟으로 지정합니다. 지정된 레벨에 화면 정렬된 사각형으로 텍스처 맵을 렌더링하여 텍스처 맵을 효과적으로 축소합니다. 이 과정은 모든 텍스처 맵 레벨이 채워질 때까지 반복됩니다.
텍스처를 렌더 타겟으로 사용하려면 텍스처 맵에서 뷰를 생성해야 합니다. 이전에 매개변수 없이 뷰를 생성했던 것과 달리, 여기서는 두 가지 추가 매개변수를 제공합니다:
let srcView = texture.createView({
baseMipLevel: 0,
mipLevelCount: 1
});
const sampler = device.createSampler({ minFilter: 'linear' });
// Loop through each mip level and renders the previous level's contents into it.
const commandEncoder = device.createCommandEncoder({});
for (let i = 1; i < textureDescriptor.mipLevelCount; ++i) {
const dstView = texture.createView({
baseMipLevel: i, // Make sure we're getting the right mip level...
mipLevelCount: 1, // And only selecting one mip level
});
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: dstView, // Render pass uses the next mip level as it's render attachment.
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: 'clear',
storeOp: 'store'
}],
});
// Need a separate bind group for each level to ensure
// we're only sampling from the previous level.
const bindGroup = device.createBindGroup({
layout: uniformBindGroupLayout,
entries: [{
binding: 0,
resource: sampler,
}, {
binding: 1,
resource: srcView,
}],
});
// Render
passEncoder.setPipeline(mipmapPipeline);
passEncoder.setBindGroup(0, bindGroup);
passEncoder.draw(4);
// what is a render pass;
passEncoder.end();
srcView = dstView;
}
device.queue.submit([commandEncoder.finish()]);
이 과정은 두 개의 텍스처 뷰를 포함합니다. 소스 뷰가 텍스처의 레벨 i를 가리키면, 대상 뷰는 레벨 i+1을 선택하여 모든 레벨이 처리될 때까지 계속됩니다.
텍스처 뷰를 생성할 때, `baseMipLevel` 매개변수는 렌더 타겟 레벨을 지정합니다. 레벨 0은 원본 이미지를 포함하고, 레벨 1은 축소된 버전을 포함하는 식입니다. `mipLevelCount`는 항상 1로 설정되어 한 번에 하나의 밉맵 레벨만 처리함을 나타냅니다.
샘플러의 경우 선형 보간을 사용합니다. 이는 원본 이미지를 축소하여 밉맵을 생성할 때 픽셀에 대한 선형 보간을 수행한다는 의미입니다. 이 작업은 이미지를 약간 흐리게 만들 수 있지만, 텍스처 맵의 더 부드러운 버전은 멀리서 보이는 객체에 실제로 바람직합니다.
이 작업은 이미지를 약간 흐리게 만들 수 있지만, 텍스처 맵의 더 부드러운 버전은 멀리서 보이는 객체에 실제로 유익합니다.
그런 다음 드로우 명령을 생성하기 위한 인코더를 생성합니다. 이 과정은 밉맵의 각 레벨에 해당하는 일련의 사각형을 그리는 것을 포함합니다.
각 반복에서 우리는 타겟의 크기를 점진적으로 줄입니다. 텍스처 맵의 소스 뷰보다 한 레벨 높은 타겟을 생성합니다. 이 접근 방식은 이미지를 점진적으로 축소할 수 있게 합니다. 각 밉맵 레벨의 너비와 높이를 명시적으로 지정할 필요가 없다는 점은 주목할 가치가 있습니다. 이는 자동으로 처리되며, 레벨이 증가함에 따라 두 차원 모두 절반으로 줄어듭니다.
대상 뷰에서 색상 첨부 파일을 생성하고 바인딩 그룹에 소스 뷰를 제공합니다. 드로우 과정에는 네 개의 인덱스가 포함됩니다. 각 반복 후에 대상 뷰를 새로운 소스 뷰로 다시 할당하고, 루프는 다음 밉맵 레벨을 계속 생성합니다.
플레이그라운드 실행 - 1_18_2_mipmaps이 명령들이 실행되면, 밉맵이 완전히 생성되어 일반 렌더링에 사용할 준비가 됩니다. 렌더링 프로세스는 이전 구현과 동일하게 유지됩니다. 중요하게도, 적절한 밉맵 레벨 선택과 이 레벨들 간의 보간은 그래픽스 파이프라인에 의해 자동으로 처리됩니다:
@group(0) @binding(2)
var t_diffuse: texture_2d;
@group(0) @binding(3)
var s_diffuse: sampler;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
return textureSample(t_diffuse, s_diffuse, in.tex_coords) ;
}
그러나 고려해야 할 중요한 주의 사항이 있습니다: `textureSample` 함수는 균일성 요구 사항(uniform control flow 내에서 호출되어야 함)을 충족해야 합니다. 균일성 제어 흐름의 개념은 이 시점에서는 이해하기 어려울 수 있으며, 나중에 더 깊이 다룰 것입니다. 현재로서는 다음과 같이 간단히 설명할 수 있습니다: 코드 조각은 셰이더 호출과 관계없이 실행되는 경우 균일성 제어 흐름에 있습니다. 즉, 유니폼과 정점 속성을 포함하여 어떤 셰이더 입력을 제공하든 해당 코드 조각은 항상 실행됩니다.
균일성 요구 사항을 충족하지 않는 반례를 살펴보겠습니다:
@group(0) @binding(2)
var t_diffuse: texture_2d;
@group(0) @binding(3)
var s_diffuse: sampler;
@fragment
fn fs_main(in: VertexOutput, @builtin(front_facing) face: bool) -> @location(0) vec4 {
if (face) {
return textureSample(t_diffuse, s_diffuse, in.tex_coords) ;
} else {
return vec4(0.0, 1.0, 0.0, 1.0); // Green for back-facing
}
} 이 코드는 균일성 요구 사항을 위반하므로 컴파일되지 않습니다. `textureSample`의 실행은 프래그먼트가 앞면을 향하는지 여부에 따라 달라지므로, 모든 호출에서 실행을 보장할 수 없습니다.
앞면을 향하는 프래그먼트에 대해서만 텍스처 맵 색상을 반환하려면 올바른 접근 방식은 조건문 외부에서 텍스처 샘플링을 수행하는 것입니다:
@group(0) @binding(2)
var t_diffuse: texture_2d;
@group(0) @binding(3)
var s_diffuse: sampler;
@fragment
fn fs_main(in: VertexOutput, @builtin(front_facing) face: bool) -> @location(0) vec4 {
let c: vec4 = textureSample(t_diffuse, s_diffuse, in.tex_coords);
if (face) {
return c;
} else {
return vec4(0.0, 1.0, 0.0, 1.0); // Green for back-facing
}
} 코드의 시각적 검사를 통해 균일성 문제를 식별하는 것이 어려워 보일 수 있지만, 다행히 WebGPU 셰이더 컴파일러는 이러한 문제를 보고하도록 설계되어 있습니다. 이러한 요구 사항의 배경에 있는 이유에 대해서는 나중에 더 깊이 파고들 것입니다.
밉매핑은 효과적이지만 한계가 있습니다. 이전 예에서는 텍스처의 샘플링 속도가 x 및 y 방향 모두에서 균일해야 한다고 가정했습니다. 그러나 항상 그런 것은 아닙니다. 비스듬한 각도에서 텍스처를 볼 때, 화면의 관점에서 텍스처는 한 방향으로 다른 방향보다 더 많이 압축될 수 있습니다. 이러한 상황에서는 다른 방향에 따라 다른 샘플링 속도를 적용해야 합니다. 이 접근 방식은 이방성 샘플링(anisotropic sampling)이라고 하며, 렌더링 품질을 크게 향상시킬 수 있습니다.
이방성 샘플링을 구현하는 것은 비교적 간단하지만, 렌더링 속도와 절충해야 합니다. 이를 활성화하려면 샘플러를 정의할 때 `maxAnisotropy > 1`로 설정합니다. 이 매개변수는 필터링 중 지원되는 최대 이방성 비율을 나타냅니다. 이방성의 효과를 더 명확하게 관찰하기 위해 위 예제의 카메라를 더 비스듬한 각도로 조정할 수 있습니다. 다음은 이방성 샘플링을 활성화하기 전과 후의 결과 비교입니다:
보시다시피, 이방성 샘플링은 특히 비스듬한 각도에서 텍스처 품질을 눈에 띄게 향상시킵니다. 상단 이미지는 표준 밉매핑 결과를 보여주는 반면, 하단 이미지는 이방성 샘플링을 통해 달성된 향상된 디테일과 왜곡 감소를 보여줍니다.
이방성 샘플링 구현은 더 탐구할 연습 문제로 남겨둡니다.