5.2 툰 셰이딩

그래픽스에서 사실적인 렌더링이 항상 목표는 아닙니다. 페인팅이나 만화 같은 예술적인 스타일도 똑같이 매력적일 수 있습니다. 이 튜토리얼에서는 간단한 만화 스타일 렌더링 기법을 살펴보겠습니다.

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

구체적으로, 우리는 두 가지 효과를 구현하는 데 집중할 것입니다. 첫째, 모델에 실루엣을 추가하는 것, 둘째, 색상이 점진적으로 변하지 않는 회화적 스타일을 만들기 위해 셰이더를 조정하는 것입니다.

실루엣부터 시작하겠습니다. 이 효과를 얻기 위한 몇 가지 방법이 있으며, 각각 장단점이 있습니다. 한 가지 방법은 화면 공간에서 불연속성을 감지하는 것입니다. 이 접근 방식은 먼저 표면 노멀, 깊이, 색상과 같은 추가 메타데이터로 장면을 렌더링해야 합니다. 두 번째 패스에서는 이러한 속성을 분석하여 불연속성을 감지하고, 이를 실루엣으로 강조합니다. 이 방법은 효과적이지만, 리소스 집약적이며 이미지 공간에서 객체 크기에 민감할 수 있습니다.

이 튜토리얼에서는 더 간단한 접근 방식을 사용할 것입니다: 객체를 약간 확대하고 뒤집는 것입니다. 확대된 객체는 순수한 검정색으로 음영 처리되어 실루엣 효과를 만듭니다. 이 방법은 비용이 많이 드는 화면 공간 후처리 필요성을 피하고 효율성 때문에 게임에서 흔히 사용됩니다.

객체를 확대하는 것은 각 정점을 객체 중심에서 일정한 비율로 떨어뜨려 오프셋하여 달성할 수 있습니다. 이 접근 방식은 대략 구형 객체에 잘 작동하지만, 길거나 복잡한 모양에는 덜 만족스러운 결과를 초래할 수 있습니다. 이러한 경우 중심에서 멀리 떨어진 영역이 더 많이 늘어나 불완전한 실루엣이 될 수 있습니다.

객체 확대 시 발생하는 아티팩트
객체 확대 시 발생하는 아티팩트[SOURCE]

더 나은 접근 방식은 각 정점을 정점 노멀을 따라 부풀리는 것입니다. 이 과정에서는 표면 노멀 대신 정점 노멀을 사용하는 것이 중요합니다. 정점 노멀은 인접한 표면 노멀을 평균하여 계산되며, 이는 날카로운 특징에 대해 더 부드럽고 정확한 결과를 제공합니다. 표면 노멀을 직접 사용하면 동일한 정점을 공유하는 표면이 완벽하게 정렬되지 않아 아티팩트가 발생할 수 있습니다. 정점 노멀을 사용하면 이러한 아티팩트를 최소화하고 더 일관된 실루엣을 얻는 데 도움이 됩니다.

표면 노멀을 사용하여 객체를 부풀릴 때 발생하는 아티팩트
표면 노멀을 사용하여 객체를 부풀릴 때 발생하는 아티팩트[SOURCE]

그러나 이 방법에는 단점이 있습니다: 실루엣의 모양이 시야 거리에 민감합니다. 객체에 가까이 있을 때는 실루엣이 너무 두껍게 보일 수 있지만, 멀리서 볼 때는 너무 얇아져 잘 눈에 띄지 않을 수 있습니다. 실루엣 두께의 이러한 변화는 만화 스타일 렌더링의 전반적인 시각적 효과에 영향을 미칠 수 있습니다.

가까이에서 볼 때 발생하는 아티팩트
가까이에서 볼 때 발생하는 아티팩트[SOURCE]

우리가 원하는 것은 시야각과 거리에 관계없이 일관된 균일한 너비의 실루엣입니다. 이는 클립 공간에서 작업함으로써 달성할 수 있습니다. 우리의 접근 방식은 정점 노멀을 클립 평면에 투영한 다음, 클립 공간에서 이러한 노멀을 기반으로 정점 위치를 오프셋하는 것입니다. 정점 노멀과 정점 위치가 모두 클립 공간에서 2D이고, 이 단계가 원근 투영 후에 발생하므로 실루엣 두께는 시야각이나 거리에 영향을 받지 않습니다.

요약하자면, 실루엣 렌더링에는 두 가지 주요 단계가 있습니다. 첫째, 약간 확대된 객체를 검정색 또는 실루엣 색상으로 렌더링한 다음, 그 위에 원본 객체를 렌더링합니다. 이를 달성하는 두 가지 방법이 있습니다:

첫 번째 방법에서는 두 번의 패스를 사용합니다. 첫 번째 패스에서는 확대된 객체를 실루엣 색상으로 렌더링하지만, 깊이 버퍼에 쓰지 않습니다. 이렇게 하면 더 큰 확대된 객체가 일반 객체를 가리지 않게 됩니다. 두 번째 패스에서는 실루엣 위에 객체를 정상적으로 렌더링합니다.

두 번째 방법은 더 간단하지만 덜 직관적입니다. 확대된 객체를 뒤집어 내부면을 실루엣 색상으로만 렌더링하고, 동시에 깊이 테스트를 활성화한 상태로 객체를 정상적으로 렌더링합니다. 객체를 뒤집음으로써 확대된 객체의 앞면은 효과적으로 벗겨져 정상적으로 렌더링된 객체를 가리지 않게 됩니다. 이 방법은 단일 렌더링 패스에서 실루엣 효과를 달성할 수 있습니다.

구현으로 들어가 보겠습니다:

@group(0) @binding(0)
var modelView: mat4x4;
@group(0) @binding(1)
var projection: mat4x4;
@group(0) @binding(2)
var normalMatrix: mat4x4;
@group(0) @binding(3)
var screenDim: vec2;

struct VertexOutput {
    @builtin(position) clip_position: vec4,
};

@vertex
fn vs_main(
    @location(0) inPos: vec3,
    @location(1) inNormal: vec3
) -> VertexOutput {
    var out: VertexOutput;
    out.clip_position = projection * modelView * vec4(inPos, 1.0);
    var clip_normal:vec4 = projection * normalMatrix * vec4(inNormal, 0.0);

    out.clip_position =vec4( out.clip_position.xy + normalize(clip_normal.xy)*6.4/screenDim * out.clip_position.w,out.clip_position.zw );
    return out;
}

// Fragment shader

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
    return vec4( 0.0,0.0,0.0,1.0);
}

셰이더 구현은 클립 공간에서 객체를 부풀립니다. 정점 셰이더는 정점 위치와 정점 노멀이라는 두 가지 매개변수를 3D 좌표로 받습니다. 먼저 정점 위치를 평소와 같이 클립 공간으로 변환합니다. 노멀 벡터의 경우, 노멀 행렬을 적용한 다음 투영을 적용합니다. 이 때 w 구성 요소는 점이 아닌 벡터를 나타내므로 0.0으로 설정됩니다.

다음으로, 클립 공간 위치를 부풀립니다. 실루엣은 2D로 표현되므로 z 및 w 구성 요소는 변경되지 않습니다. x 및 y 구성 요소의 경우, normalize(clip_normal.xy) * 6.4 / screenDim * out.clip_position.w 만큼 오프셋합니다. 여기서 클립 공간 노멀을 화면 크기로 나누는 것은 화면 크기 및 종횡비에 대한 보상을 제공하여 실루엣 너비가 다양한 화면 크기 및 종횡비에 걸쳐 일관되게 유지되도록 합니다. out.clip_position.w를 곱하는 것은 그래픽 파이프라인이 클립 공간 위치를 정규화된 장치 좌표로 변환할 때 w로 나누기 때문에 필요합니다. 이 변환 중 실루엣 두께의 변화를 방지하기 위해 w를 미리 곱합니다.

프래그먼트 셰이더는 간단하며, 실루엣을 렌더링하기 위해 순수한 검은색 픽셀을 출력합니다.

이제 JavaScript 코드를 살펴보겠습니다:

let { positionBuffer, normalBuffer, indexBuffer, indexSize } = await loadObj(device, '../data/teapot.obj');
this.positionBuffer = positionBuffer;
// The normal buffer contains vertex normals calculated as averages of adjacent surface normals.
this.normalBuffer = normalBuffer;
this.indexBuffer = indexBuffer;
this.indexSize = indexSize;
• • •
const outlinePipelineDesc = {
    layout: device.createPipelineLayout(outlinePipelineLayoutDesc),
    vertex: {
        module: shaderModuleOutline,
        entryPoint: 'vs_main',
        buffers: [positionBufferLayoutDesc, normalBufferLayoutDesc]
    },
    fragment: {
        module: shaderModuleOutline,
        entryPoint: 'fs_main',
        targets: [colorState]
    },
    primitive: {
        topology: 'triangle-list',
        frontFace: 'ccw',
        cullMode: 'front'
    },
    depthStencil: {
        depthWriteEnabled: true,
        depthCompare: 'less',
        format: 'depth32float'
    }
}

OBJ 파일을 로드할 때, 인접한 표면 노멀의 평균으로 계산된 정점 노멀을 포함하는 노멀 버퍼를 이미 설정했습니다. 아웃라인 렌더링을 위한 파이프라인을 설정할 때, 확대된 객체의 전면을 제거하기 위해 cullMode를 front로 구성합니다. 또한 적절한 깊이 처리를 위해 깊이 테스트가 활성화됩니다.

이 튜토리얼의 두 번째 부분에서는 회화적인 음영 효과를 얻는 데 중점을 둘 것입니다. 사실적인 렌더링과 달리, 회화적인 스타일은 부드러운 그라데이션보다는 불연속적인 색상 전환을 특징으로 합니다. 이러한 스타일은 특정 예술적 효과를 목표로 물리적 조명 방정식에 따르지 않는 "거짓" 색상을 사용할 수 있습니다.

이 회화적 효과를 만들기 위해, 우리는 색상을 이산화하기 위한 룩업 테이블(LUT)을 도입합니다. 조명 계산에는 계속해서 퐁 셰이딩 알고리즘을 사용하지만, 더 이상 최종 색상을 직접 계산하지 않습니다. 대신, 광원 강도를 단일 값으로 계산하고 미리 만들어진 LUT를 사용하여 이 강도를 색상으로 변환합니다. 우리의 1D LUT는 [0,1] 범위의 값을 임의의 RGB 색상에 매핑합니다. 우리의 설정에서는 1D 텍스처가 최종 이미지를 렌더링하는 데 사용될 거짓 색상 대역을 포함합니다.

이 효과에 사용되는 셰이더를 살펴보겠습니다. 이 셰이더는 그림자 맵 셰이더를 기반으로 하지만 회화적 음영을 위한 LUT를 포함하도록 적용되었습니다.

// 색상을 RGB로 설정하는 대신, 강도만 계산하기 위해 스칼라를 사용합니다.
const diffuseConstant:f32 = 1.0;
const specularConstant:f32 = 0.0;
const ambientConstant:f32 = 0.0;
• • •
// 동일한 퐁 셰이딩을 적용하지만, 최종 색상을 도출하는 대신 강도를 얻습니다.
var intensity:f32 = max(dot(-lightDir, n), 0.0)* diffuseConstant + specular(-lightDir, viewDir, n, shininess) * specularConstant;
// 광원 강도를 사용하여 최종 색상을 룩업합니다.
var diffuse:vec3 = textureSample(t_shade, s_shade, intensity * visibility).xyz;

마지막으로, JavaScript 측면에서 이 1D 룩업 텍스처가 어떻게 설정되는지 살펴보겠습니다.

// 1D 텍스처, 너비는 128로 하드코딩됩니다.
const shadeTextureDesc = {
    size: [128],
    dimension: '1d',
    format: "rgba8unorm",
    usage: GPUTextureUsage.COPY_DST | GPUTextureUsage.TEXTURE_BINDING
};
// 색상 데이터를 채웁니다. 이 룩업 테이블은 4개의 색상 대역을 정의합니다.
let shadeTextureColors = [];
for (let i = 0; i < 128; ++i) {
    if (i < 40) {
        shadeTextureColors.push(95);
        shadeTextureColors.push(121);
        shadeTextureColors.push(127);
        shadeTextureColors.push(255);
    }
    else if (i >= 40 && i < 80) {
        shadeTextureColors.push(143);
        shadeTextureColors.push(181);
        shadeTextureColors.push(191);
        shadeTextureColors.push(255);
    }
    else if (i >= 80 && i < 124) {
        shadeTextureColors.push(191);
        shadeTextureColors.push(242);
        shadeTextureColors.push(255);
        shadeTextureColors.push(255);
    }
    else {
        shadeTextureColors.push(255);
        shadeTextureColors.push(255);
        shadeTextureColors.push(255);
        shadeTextureColors.push(255);
    }
}
// 텍스처를 생성하고 데이터를 복사합니다.
let shadeTexture = device.createTexture(shadeTextureDesc);
device.queue.writeTexture({ texture: shadeTexture }, new Uint8Array(shadeTextureColors), {
    offset: 0,
    bytesPerRow: 128 * 4,
    rowsPerImage: 1
}, { width: 128 });
// 완료될 때까지 기다립니다.
await device.queue.onSubmittedWorkDone();

아웃라인 셰이더와 함께 이제 툰 셰이딩 효과를 얻을 수 있습니다:

툰 셰이딩 주전자
툰 셰이딩 주전자

GitHub에 의견 남기기