1.4 다른 정점 색상 사용하기
이 튜토리얼에서는 이전 예제에 또 다른 미묘한 수정을 가할 것입니다. 셰이더에 색상을 하드 코딩하는 대신, 정점 색상을 데이터로 셰이더에 전달하여 3D 렌더링에 더 유연하고 사실적인 접근 방식을 보여줄 것입니다.
플레이그라운드 실행 - 1_04_different_vertex_colors셰이더 코드의 변경 사항을 살펴보겠습니다. @location(0)에 있는 위치 속성 외에도, @location(1)에 inColor라는 새 입력 매개변수를 도입했습니다. 이 inColor 매개변수는 색상의 빨강, 녹색, 파랑 구성 요소를 나타내는 세 개의 부동 소수점 벡터입니다. 이전에 out.color 필드를 하드 코딩했던 정점 단계에서는 이제 단순히 입력 inColor를 할당합니다. 셰이더 코드의 나머지 부분은 변경되지 않습니다.
이 예제는 정점 위치 외에 여러 속성을 다른 위치 인덱스를 사용하여 전달하는 방법을 보여줍니다. 이는 셰이더 프로그래밍의 중요한 개념으로, 더 복잡하고 다양한 렌더링 효과를 가능하게 합니다.
struct VertexOutput {
@builtin(position) clip_position: vec4,
@location(0) color: vec3,
};
@vertex
fn vs_main(
@location(0) inPos: vec3,
@location(1) inColor: vec3
) -> VertexOutput {
var out: VertexOutput;
out.clip_position = vec4(inPos, 1.0);
out.color = inColor;
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4 {
return vec4(in.color, 1.0);
}
각 정점에 다른 색상을 할당함으로써, 우리는 이전에 제기했던 흥미로운 질문에 답할 수 있는 위치에 서게 됩니다. 삼각형의 중앙에 위치한 프래그먼트의 색상은 어떻게 생성될까요? 이 설정은 GPU의 색상 보간(interpolation)을 실제로 관찰할 수 있게 하여, 렌더링 파이프라인의 정점 및 프래그먼트 단계 사이에서 데이터가 어떻게 처리되는지에 대한 시각적인 시연을 제공할 것입니다.
이제 새 셰이더를 위한 파이프라인을 설정하는 방법을 살펴보겠습니다. 새 파이프라인은 이전 파이프라인과 매우 유사하며, 주요 차이점은 이제 모든 정점의 색상을 포함하는 색상 버퍼를 생성하여 파이프라인에 공급해야 한다는 것입니다.
관련된 단계는 위치 버퍼를 처리했던 방식과 매우 유사합니다. 먼저, 색상 속성 디스크립터를 생성합니다. 셰이더의 @location(1)에 있는 inColor 속성에 해당하는 shaderLocation을 1로 설정했음에 유의하세요. 색상 속성의 형식은 세 개의 부동 소수점 벡터로 유지됩니다.
const colorAttribDesc = {
shaderLocation: 1, // @location(1)
offset: 0,
format: 'float32x3'
};
const colorBufferLayoutDesc = {
attributes: [colorAttribDesc],
arrayStride: 4 * 3, // sizeof(float) * 3
stepMode: 'vertex'
};
const colors = new Float32Array([
1.0,
0.0,
0.0, // 🔴
0.0,
1.0,
0.0, // 🟢
0.0,
0.0,
1.0 // 🔵
]);
let colorBuffer = createGPUBuffer(device, colors, GPUBufferUsage.VERTEX);
다음으로, 각 정점에 대한 색상 버퍼를 파이프라인이 어떻게 해석해야 하는지 알려주는 버퍼 레이아웃 디스크립터를 생성합니다. 색상 속성 디스크립터를 attributes 필드에 할당합니다. arrayStride는 부동 소수점이 4바이트를 차지하고 각 색상에 3개의 부동 소수점이 있으므로 4 * 3으로 설정됩니다. stepMode는 각 정점이 하나의 색상을 가지므로 vertex로 설정됩니다.
CPU 메모리에 Float32Array를 사용하여 RGB 데이터를 정의한 후(첫 번째 정점은 빨강, 두 번째는 녹색, 세 번째는 파랑), GPU 버퍼를 생성하고 데이터를 GPU로 복사합니다.
GPU 버퍼를 생성하고 채우는 과정을 요약해 보겠습니다:
-
버퍼 크기를 지정하고, 정점 단계에서 색상 속성을 사용할 것이므로 usage 플래그를
VERTEX로 설정하여 버퍼 디스크립터를 정의합니다. -
mappedAtCreation을 true로 설정하여 버퍼 생성 시 즉시 데이터 복사를 허용합니다. -
GPU 버퍼를 생성하고, 매핑된 버퍼 범위를 사용하여 CPU 메모리에 미러링된 버퍼를 생성합니다.
-
이 매핑된 버퍼에 색상 데이터를 복사합니다.
-
마지막으로, 버퍼를 언매핑하여 데이터 전송이 완료되었음을 알립니다.
샘플 코드에서는 이러한 단계를 명시적으로 볼 수 없을 것입니다. 이는 WebGPU 프로그램 전체에서 여러 번 수행해야 하는 일반적인 절차이기 때문입니다. 코드를 간소화하고 반복을 줄이기 위해, 저는 이러한 단계를 캡슐화하는 유틸리티 함수를 만들었습니다.
이전에 언급했듯이, WebGPU는 문법이 다소 장황할 수 있습니다. 일반적인 코드 블록을 재사용 가능한 유틸리티 함수로 래핑하는 것이 좋은 습관입니다. 이 접근 방식은 작업량을 줄일 뿐만 아니라 코드를 더 읽기 쉽고 유지 관리하기 쉽게 만듭니다.
제가 만든 createGPUBuffer 함수는 이 모든 단계를 하나의 재사용 가능한 함수로 캡슐화합니다. 정의는 다음과 같습니다:
function createGPUBuffer(device, buffer, usage) {
const bufferDesc = {
size: buffer.byteLength,
usage: usage,
mappedAtCreation: true
};
//console.log('buffer size', buffer.byteLength);
let gpuBuffer = device.createBuffer(bufferDesc);
if (buffer instanceof Float32Array) {
const writeArrayNormal = new Float32Array(gpuBuffer.getMappedRange());
writeArrayNormal.set(buffer);
}
else if (buffer instanceof Uint16Array) {
const writeArrayNormal = new Uint16Array(gpuBuffer.getMappedRange());
writeArrayNormal.set(buffer);
}
else if (buffer instanceof Uint8Array) {
const writeArrayNormal = new Uint8Array(gpuBuffer.getMappedRange());
writeArrayNormal.set(buffer);
}
else if (buffer instanceof Uint32Array) {
const writeArrayNormal = new Uint32Array(gpuBuffer.getMappedRange());
writeArrayNormal.set(buffer);
}
else {
const writeArrayNormal = new Float32Array(gpuBuffer.getMappedRange());
writeArrayNormal.set(buffer);
console.error("Unhandled buffer format ", typeof gpuBuffer);
}
gpuBuffer.unmap();
return gpuBuffer;
}
이 시점에서, 우리는 GPU에 색상 값을 성공적으로 복제했으며, 이제 셰이더에서 사용할 준비가 되었습니다.
const pipelineDesc = {
layout,
vertex: {
module: shaderModule,
entryPoint: 'vs_main',
buffers: [positionBufferLayoutDesc, colorBufferLayoutDesc]
},
fragment: {
module: shaderModule,
entryPoint: 'fs_main',
targets: [colorState]
},
primitive: {
topology: 'triangle-list',
frontFace: 'cw',
cullMode: 'back'
}
};
pipeline = device.createRenderPipeline(pipelineDesc);
commandEncoder = device.createCommandEncoder();
passEncoder = commandEncoder.beginRenderPass(renderPassDesc);
passEncoder.setViewport(0, 0, canvas.width, canvas.height, 0, 1);
passEncoder.setPipeline(pipeline);
passEncoder.setVertexBuffer(0, positionBuffer);
passEncoder.setVertexBuffer(1, colorBuffer);
passEncoder.draw(3, 1);
passEncoder.end();
device.queue.submit([commandEncoder.finish()]);
버퍼를 생성한 후, 파이프라인 디스크립터를 정의합니다. 이전 예제와의 주요 차이점은 정점 단계의 buffers 목록에 colorBufferLayoutDescriptor가 추가되었다는 것입니다. 이는 파이프라인에 이제 위치용 버퍼 하나와 색상용 버퍼 하나, 총 두 개의 정점 버퍼를 사용하고 있음을 알려줍니다.
렌더 명령을 인코딩할 때, 이제 두 개의 정점 버퍼를 설정해야 합니다. 위치 데이터에는 setVertexBuffer(0, positionBuffer)를 사용하고, 색상 데이터에는 setVertexBuffer(1, colorBuffer)를 사용합니다. 인덱스 0과 1은 파이프라인 디스크립터를 정의할 때의 버퍼 레이아웃에 해당합니다. 렌더링 과정의 나머지 부분은 거의 변경되지 않습니다.
이 코드를 실행하면 시각적으로 흥미로운 결과, 즉 다채로운 삼각형이 나타납니다. 각 정점은 지정된 색상(빨강, 녹색, 파랑)으로 렌더링됩니다. 그러나 가장 흥미로운 측면은 이러한 정점 사이에서 발생하는 현상입니다. 우리는 삼각형 표면을 가로지르는 색상의 부드러운 전환을 관찰합니다.
이 자동 색상 혼합은 GPU가 수행하는 기능으로, 이를 보간(interpolation)이라고 부릅니다. 이 보간이 색상에만 국한되지 않는다는 점이 중요합니다. 우리가 정점 단계에서 출력하는 모든 값은 GPU에 의해 보간되어 모든 프래그먼트에 적절한 값을 할당하며, 특히 정점에 직접 위치하지 않는 프래그먼트에 대해서도 그렇게 합니다.
프래그먼트 값의 보간은 이선형 방식에 따라 정점으로부터의 상대적인 거리를 기반으로 계산됩니다. 이 메커니즘은 매우 유용합니다. 장면에는 일반적으로 정점보다 프래그먼트가 훨씬 더 많기 때문에, 모든 프래그먼트의 값을 개별적으로 지정하는 것은 비현실적일 것입니다. 대신, 우리는 각 정점에서 정의된 값만을 기반으로 GPU가 이러한 값을 효율적으로 생성하도록 의존합니다.
이 보간 기법은 컴퓨터 그래픽스의 기본 개념으로, 최소한의 입력 데이터로 표면 전체에 걸쳐 부드러운 전환과 그라데이션을 가능하게 합니다.