5.1 섀도우 매핑

이전 레슨에서 조명에 대해 다루었지만, 사실적인 그래픽을 구현하려면 조명만으로는 부족합니다. 현재 우리의 조명 모델은 매우 기본적인데, 표면 노멀이 광원을 향하면 표면이 밝아집니다. 하지만 실제로는 물체가 그림자를 드리우기 때문에, 물체에 의해 광원에서 가려진 표면 영역은 노멀이 광원을 향하더라도 어둡게 보여야 합니다.

플레이그라운드 실행 - 5_01_shadow_maps

이 튜토리얼에서는 섀도우 매핑이라는 기술을 사용하여 그림자를 구현하는 방법을 알아보겠습니다. 이 기술을 사용하면 그림자 영역과 빛이 비치는 영역의 경계가 명확하게 구분되는 하드 섀도우를 만들 수 있습니다. 하드 섀도우는 이상적인 점 광원을 시뮬레이션하여 생성할 수 있지만, 주변 영역 광원으로부터 발생하는 소프트 섀도우와 같은 더 복잡한 시나리오는 다루지 않습니다.

섀도우 매핑의 개념은 간단합니다. 먼저, 광원 시점에서 장면을 렌더링하여 깊이 맵을 생성합니다. 이 깊이 맵은 광원에서 빛에 의해 직접 비춰지는 모든 보이는 표면까지의 최단 거리를 기록합니다.

나중에 카메라 시점에서 장면을 렌더링할 때, 각 프래그먼트에서 광원까지의 거리를 계산하고 이를 깊이 맵의 해당 값과 비교합니다. 거리가 깊이 맵 값과 같거나 작으면 프래그먼트는 광원에 의해 빛이 비춰진 것으로 간주됩니다. 그렇지 않고 거리가 더 크면 프래그먼트는 가려진 것으로 간주되어 더 어두운 색상으로 렌더링됩니다.

우리의 코드는 이전의 조명 코드를 기반으로 할 것입니다. 첫 번째 단계는 점 광원을 스포트라이트로 조정하는 것입니다. 모든 방향으로 광선을 방출하는 점 광원과 달리, 스포트라이트는 좁은 조명 원뿔을 가지며 이 원뿔 내의 장면에만 영향을 미칩니다.

광원 시점에서 렌더링할 때, 스포트라이트에 호환되는 뷰 프러스텀을 정의해야 합니다.

스포트라이트
스포트라이트

스포트라이트를 구현하는 것은 간단합니다. 원래 점 광원 셰이더에 몇 가지 조정만 하면 됩니다.

if (face) {
    var wldLoc2light:vec3 =   in.wldLoc-lightLoc;
    if (align > 0.9) {
        var radiance:vec3  = ambientColor.rgb * ambientConstant + 
            diffuse(-lightDir, n, diffuseColor.rgb)* diffuseConstant +
            specular(-lightDir, viewDir, n, specularColor.rgb, shininess) * specularConstant;

        return vec4(radiance * visibility ,1.0);
    }
} 
return vec4( 0.0,0.0,0.0,1.0);

이 셰이더에서 `wldLoc2light`는 프래그먼트의 월드 위치에서 광원의 위치까지의 벡터를 나타내고, `lightDir`는 광원 방향 벡터입니다. 이 두 벡터의 내적을 계산하여 내적이 0.9의 임계값을 초과하는 경우에만 프래그먼트를 밝힙니다. 이는 벡터 간의 각도가 충분히 작다는 것을 나타냅니다. 두 벡터 모두 월드 좌표계에 있으며 아직 투영 행렬에 의해 변환되지 않았습니다.

var wldLoc:vec4 = modelView * vec4(inPos, 1.0);
out.clip_position = projection * wldLoc;
out.wldLoc = wldLoc.xyz / wldLoc.w;
out.inPos = inPos;
var lightLoc:vec4 = modelView * vec4(lightDirection, 1.0);
out.lightLoc = lightLoc.xyz / lightLoc.w;

점 광원을 스포트라이트로 변환한 후, 다음 단계는 광원에 정렬된 가상 카메라에서 장면을 렌더링하여 깊이 맵 또는 섀도우 맵을 생성하는 것입니다.

단계별 접근 방식을 취해봅시다: 먼저, 최종 단계에서 그림자 효과를 만들기 전에 깊이 맵을 덤프하고 시각화할 것입니다.

@group(0) @binding(0)
var modelView: mat4x4;
@group(0) @binding(1)
var projection: mat4x4;

struct VertexOutput {
    @builtin(position) clip_position: vec4,
    @location(0) depth: f32
};

@vertex
fn vs_main(
    @location(0) inPos: vec3
) -> VertexOutput {
    var out: VertexOutput;
    var wldLoc:vec4 = modelView * vec4(inPos, 1.0);
    out.clip_position = projection * wldLoc;
    out.depth = out.clip_position.z / out.clip_position.w;
    return out;
}

struct FragOutputs {
    @builtin(frag_depth) depth: f32,
    @location(0) color: vec4
  }

// Fragment shader
@fragment
fn fs_main(in: VertexOutput,   @builtin(front_facing) isFront: bool) -> FragOutputs {
    var out:FragOutputs;
    if (isFront) {
        out.depth = in.depth;
    }
    else {
        out.depth = in.depth -0.001;
    }
    out.color = vec4(0.0,1.0,0.0,1.0);
    return out;
}

제시된 셰이더는 간단하며 익숙할 것입니다. 명확히 하자면, 깊이 맵은 광원의 관점에서 렌더링되므로, `modelView`와 `projection` 행렬은 카메라가 아닌 광원의 시점에서 파생됩니다. 표준 프래그먼트 셰이더에서는 깊이 계산이 그래픽스 파이프라인에 의해 자동으로 처리됩니다. 그러나 이 경우, 우리는 깊이 값을 수동으로 계산하여 텍스처 맵에 기록해야 합니다. 이는 z-값이 깊이를 나타내는 클립 공간 위치를 사용하여 수행됩니다.

셰이더 코드의 주목할 만한 부분은 다음과 같습니다:

if (isFront) {
    out.depth = in.depth;
}
else {
    out.depth = in.depth -0.001;
}

여기서 셰이더는 현재 프래그먼트가 앞면을 향하는지 확인합니다. 그렇다면 깊이 값이 직접 출력됩니다. 프래그먼트가 앞면을 향하지 않으면, 깊이는 카메라에 더 가깝게 약간 조정됩니다. 이 조정은 수치 정밀도 문제로 인한 아티팩트를 완화하는 데 도움이 됩니다. 전체 프로그램을 구현한 후 이 조정의 영향을 다시 검토하고 평가할 수 있습니다.

다음으로, 매개변수가 계산되어 셰이더로 전달되는 방식을 이해하기 위해 JavaScript 측면을 살펴보겠습니다. 특히, 찻주전자 주위를 원형으로 움직이도록 광원을 위한 모델-뷰 행렬을 생성해야 합니다. 각 렌더링 반복마다 광원의 각도를 조정하고 그에 따라 모델-뷰 행렬을 다시 계산합니다.

let lightDir = glMatrix.vec3.fromValues(Math.cos(angle) * 8.0, Math.sin(angle) * 8.0, 10);
let lightDirectionUniformBufferUpdate = createGPUBuffer(device, lightDir, GPUBufferUsage.COPY_SRC);
spotlight.upsertSpotLight(spotLightId, lightDir, glMatrix.vec3.fromValues(-Math.cos(angle) * 8.0, -Math.sin(angle) * 8.0, -10), glMatrix.vec3.fromValues(0.0, 1.0, 0.0));
spotlight.refreshBuffer(device);

let lightModelViewMatrix = glMatrix.mat4.lookAt(glMatrix.mat4.create(),
    glMatrix.vec3.fromValues(Math.cos(angle) * 8.0, Math.sin(angle) * 8.0, 10),
    glMatrix.vec3.fromValues(0, 0, 0), glMatrix.vec3.fromValues(0.0, 0.0, 1.0));

let lightModelViewMatrixUniformBufferUpdate = createGPUBuffer(device, lightModelViewMatrix, GPUBufferUsage.COPY_SRC);

자주 변경되지 않는 광원의 투영 행렬은 한 번만 초기화합니다.

let lightProjectionMatrix = glMatrix.mat4.perspective(glMatrix.mat4.create(),
    Math.acos(0.9) * 2.0, 1.0, 1.0, 100.0);

let lightProjectionMatrixUniformBuffer = createGPUBuffer(device, lightProjectionMatrix, GPUBufferUsage.UNIFORM);

여기서 수직 시야각은 셰이더에서 사용된 0.9 가시성 임계값에 해당하는 `Math.acos(0.9) * 2.0`으로 하드코딩되어 있습니다.

섀도우 맵을 시각화하기 위해 다음 코드를 사용합니다.

let copiedBuffer = createGPUBuffer(device, new Float32Array(1024 * 1024), GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ);
• • •
commandEncoder.copyTextureToBuffer({ texture: lightDepthTexture, origin: { x: 0, y: 0 } }, { buffer: copiedBuffer, bytesPerRow: 1024 * 4 }, { width: 1024, height: 1024 });
• • •
if (!hasDumped) {
    hasDumped = true;
    await copiedBuffer.mapAsync(GPUMapMode.READ, 0, 1024 * 1024 * 4);

    const d = new Float32Array(copiedBuffer.getMappedRange());
    const x = new Uint8ClampedArray(1024 * 1024 * 4);
    let maxv = -900;
    let minv = 900;
    for (let i = 0; i < 1024 * 1024; ++i) {
        const v = d[i];

        if (maxv < v) {
            maxv = v;
        }
        if (minv > v) {
            minv = v;
        }
        x[i * 4] = v * 255.0;
        x[i * 4 + 1] = v * 255.0;
        x[i * 4 + 2] = v * 255.0;
        x[i * 4 + 3] = v * 255.0;
    }
    copiedBuffer.unmap();
    const imageData = new ImageData(x, 1024, 1024);
    imagedataToImage(imageData);
    console.log("max min: ", maxv, minv);
}

이 코드는 GPU에서 섀도우 맵을 `copiedBuffer`라는 버퍼로 읽어오는데, 이 버퍼는 초기에는 Float32 형식입니다. 그런 다음 시각화를 위해 이 데이터를 Uint8 형식으로 변환합니다. 결과 `ImageData`는 추가 분석을 위한 이미지를 생성하는 데 사용됩니다.

광원 시점에서 본 섀도우 맵
광원 시점에서 본 섀도우 맵

마지막 단계에서는 섀도우 맵을 사용하여 그림자를 생성합니다. 먼저 이를 담당하는 셰이더 코드를 살펴보겠습니다.

var fragmentPosInShadowMapSpace: vec4 = lightProjectionMatrix * lightModelViewMatrix * vec4(in.inPos, 1.0);
fragmentPosInShadowMapSpace = fragmentPosInShadowMapSpace / fragmentPosInShadowMapSpace.w;
var depth: f32 = fragmentPosInShadowMapSpace.z;

여기서 `inPos`는 정점 위치를 나타냅니다. 광원의 투영 및 모델-뷰 행렬을 사용하여 광원 시점에서의 깊이를 계산합니다. 이 접근 방식은 이전에 셰이더에서 사용된 깊이 계산 방법을 따릅니다.

var uv:vec2 = 0.5*(fragmentPosInShadowMapSpace.xy + vec2(1.0,1.0));

var visibility = 0.0;
    let oneOverShadowDepthTextureSize = 1.0 / 1024.0;
    for (var y = -2; y <= 2; y++) {
      for (var x = -2; x <= 2; x++) {
        let offset = vec2(vec2(x, y)) * oneOverShadowDepthTextureSize;
  
        visibility += textureSampleCompare(
            t_depth, s_depth,
            vec2(uv.x, 1.0-uv.y) + offset,depth  - 0.0003
        );
      }
    }
    visibility /= 25.0;

`uv` 좌표는 `fragmentPosInShadowMapSpace`에서 계산되며, 이를 [-1, 1] 범위에서 [0, 1] 범위로 변환합니다.

시각적 품질을 향상시키기 위해 프래그먼트의 깊이를 섀도우 맵의 해당 값과 직접 비교하는 대신, 프래그먼트 위치 주변의 5x5 픽셀 영역을 샘플링하여 평균 가시성을 계산합니다. 섀도우 맵은 1024x1024 픽셀로 고정되어 있으므로, 각 픽셀의 너비와 높이는 1.0 / 1024.0입니다. 이 단위 크기를 사용하여 오프셋으로 UV 좌표를 조정하고, `vec2(uv.x, 1.0-uv.y) + offset`을 사용하여 좌표를 변환합니다. 이 조정은 텍스처 맵에서 v-좌표가 위에서 아래로 0에서 1까지 범위인 반면, 화면 공간에서는 y-축이 뒤집혀 있기 때문에 y-좌표를 뒤집습니다.

텍스처 샘플링은 `textureSampleCompare`라는 비교 함수를 사용합니다. 이 함수는 비교를 위해 `depth - 0.0003`이라는 추가 참조 값을 필요로 합니다. 섀도우 맵의 깊이 값이 이 참조 값보다 작으면 함수는 1.0을 반환하고, 그렇지 않으면 0.0을 반환합니다. 이 비교 방법은 나중에 검토할 JavaScript 코드에서 구성됩니다. 셰이더에서는 비교가 "less"로 설정되어, 참조 값보다 작은 샘플 값이 비교를 통과함을 의미합니다.

작은 오프셋(0.0003)을 사용하는 것은 수치 오차로 인한 아티팩트를 방지하는 데 중요합니다. 예를 들어, 공이 조명되고 다른 물체가 없는 경우, 공은 이상적으로 밝게 비춰져야 합니다. 그러나 수치 오차로 인해 일부 프래그먼트는 섀도우 맵의 깊이보다 작거나 큰 깊이를 가질 수 있어 무작위 그림자 아티팩트가 발생할 수 있습니다. 깊이를 약간 앞으로 조정함으로써 표면의 깊이가 항상 섀도우 맵의 자체 깊이 값보다 작도록 보장하여 자체 가려짐을 방지합니다.

마지막으로, 가시성을 기반으로 표면 색상을 결정합니다. 광선 계산은 두 가지 조건이 충족될 때만 발생합니다: 프래그먼트가 스포트라이트의 프러스텀 내에 있고 그림자 안에 있지 않은 경우입니다.

if (face) {
    var wldLoc2light:vec3 =   in.wldLoc-lightLoc;
    if (align > 0.9) {
        var radiance:vec3  = ambientColor.rgb * ambientConstant + 
            diffuse(-lightDir, n, diffuseColor.rgb)* diffuseConstant +
            specular(-lightDir, viewDir, n, specularColor.rgb, shininess) * specularConstant;

        return vec4(radiance * visibility ,1.0);
    }
} 
return vec4( 0.0,0.0,0.0,1.0);

최종 렌더링 결과
최종 렌더링 결과

코드에서 적용했던 일부 조정들을 제거했을 때의 영향과 그로 인해 발생하는 아티팩트를 살펴보겠습니다.

첫째, 뒷면을 향하는 표면에 적용된 깊이 오프셋을 제거하면 다음과 같습니다.

if (isFront) {
    out.depth = in.depth;
}
else {
    out.depth = in.depth -0.001;
}

뒷면 표면에 오프셋을 적용하지 않으면 밴딩 아티팩트가 보입니다
뒷면 표면에 오프셋을 적용하지 않으면 밴딩 아티팩트가 보입니다

오프셋이 제거되면, 뒷면을 향하는 표면은 앞면을 향하는 표면과 동일한 깊이 값을 사용합니다. 이로 인해 깊이 테스트가 일부 뒷면 프래그먼트를 실제보다 더 가깝다고 잘못 판단할 수 있어 예기치 않은 그림자 또는 빛샘 현상이 발생할 수 있습니다. 아티팩트 이미지는 이러한 문제를 명확하게 보여줍니다.

var uv:vec2 = 0.5*(fragmentPosInShadowMapSpace.xy + vec2(1.0,1.0));

var visibility = 0.0;
    let oneOverShadowDepthTextureSize = 1.0 / 1024.0;
    for (var y = -2; y <= 2; y++) {
      for (var x = -2; x <= 2; x++) {
        let offset = vec2(vec2(x, y)) * oneOverShadowDepthTextureSize;
  
        visibility += textureSampleCompare(
            t_depth, s_depth,
            vec2(uv.x, 1.0-uv.y) + offset,depth  - 0.0003
        );
      }
    }
    visibility /= 25.0;

둘째, 섀도우 맵을 이웃을 평균화하는 대신 단일 샘플만 사용하여 샘플링하면 깊이 비교가 스무딩 없이 직접 수행됩니다.

섀도우 맵을 한 번만 샘플링할 때의 아티팩트
섀도우 맵을 한 번만 샘플링할 때의 아티팩트

단일 샘플을 사용하면 특히 복잡한 그림자 세부 사항이 있는 영역에서 상당한 앨리어싱과 노이즈가 발생할 수 있습니다. 이웃 평균화의 부재는 깊이의 작은 변화가 불일치하는 그림자를 유발하여 아티팩트 이미지에 표시된 것처럼 더 픽셀화되고 덜 부드러운 그림자 효과로 이어질 수 있습니다.

마지막으로, 자체 가려짐을 피하기 위해 깊이를 오프셋하는 0.0003 꼼수를 제거하면 다음과 같습니다.

0.0003 깊이 접근 꼼수 아티팩트
0.0003 깊이 접근 꼼수 아티팩트

이 오프셋이 없으면, 프래그먼트와 섀도우 맵의 깊이 값이 매우 가깝지만 수치 정밀도 문제로 인해 동일하지 않은 경우 깊이 비교가 실패할 수 있습니다. 이로 인해 표면의 일부가 잘못 그림자지거나 조명된 것처럼 보이는 아티팩트가 발생할 수 있습니다. 이는 프래그먼트의 깊이가 섀도우 맵에서 자체 뒤에 있는 것으로 의도치 않게 간주되어 잘못된 자체 그림자 효과를 유발할 수 있기 때문입니다. 아티팩트 이미지는 표면이 부적절하게 그림자지거나 밝게 보이는 이러한 문제들을 강조합니다.

GitHub에 의견 남기기