이 글에서는 지난 글에 이어서, 스토리지 버퍼에 대해 알아보겠습니다.
스토리지 버퍼는 여러 면에서 uniform 버퍼와 비슷합니다.
자바스크립트에서 UNIFORM을 STORAGE로 바꾸고, WEGSL 예시에서는
var<uniform>를 var<storage, read>로 바꾸면 이전 예제가 그대로 동작합니다.
차이점은 아래와 같습니다. 변수 이름은 수정하지 않았습니다.
const staticUniformBuffer = device.createBuffer({
label: `static uniforms for obj: ${i}`,
size: staticUniformBufferSize,
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
...
const uniformBuffer = device.createBuffer({
label: `changing uniforms for obj: ${i}`,
size: uniformBufferSize,
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
WSGL에서는 아래와 같습니다.
-@group(0) @binding(0) var<uniform> ourStruct: OurStruct; -@group(0) @binding(1) var<uniform> otherStruct: OtherStruct; +@group(0) @binding(0) var<storage, read> ourStruct: OurStruct; +@group(0) @binding(1) var<storage, read> otherStruct: OtherStruct;
더이상 다른 수정 없이도, 이전과 같이 동작합니다.
uniform 버퍼와 스토리지 버퍼의 주요한 차이점은 아래와 같습니다:
사용 법에 따라 다를 수 있습니다. 일반적인 앱에서는 여러가지 다른 것들을 그려야 합니다. 3D 게임의 예를 들어보죠. 자동차, 건물, 바위, 덤불, 사람 등등을 그려야 합니다. 이들 각각에 대한 방향(orientation)과 머티리얼(material) 속성 등을 위 예제와 같이 넘겨주어야 합니다. 이러한 경우 uniform 버퍼를 사용하는 것이 좋은 방법입니다.
스토리지 버퍼는 uniform 버퍼보다 훨씬 클 수 있음
모든 webgpu 구현체는 최소 이런 크기들을 보장합니다. 이러한 제약에 대해서는 다른 글에서 설명합니다.
스토리지 버퍼는 읽기/쓰기가 가능하지만 uniform 버퍼는 읽기 전용
스토리지 버퍼에 값을 쓰는 예제를 첫 번째 글의 컴퓨트 셰이더 예제에서 이미 살펴본 바 있습니다.
위의 첫 두 차이점을 바탕으로 우리의 예제를 한 번의 드로우콜(draw call)로
100개 삼각형을 모두 그리는 예제로 바꾸어 봅시다.
이러한 경우 스토리지 버퍼를 사용하는 것이 적절할 수도 있습니다.
그럴 수도 있다고 이야기 한 이유는 WebGPU가 다른 프로그래밍 언어와 비슷하기 때문입니다.
동일한 목표를 다른 방법으로 달성할 수 있습니다.
array.forEach vs for (const elem of array) vs for (let i = 0; i < array.length; ++i) 모두 가능하죠.
각자는 각자의 용도가 있고, WebGPU도 마찬가지 입니다.
우리가 하는 모든 것들의 달성하는 방법은 여러가지 입니다.
삼각형을 그리는 경우에 대해선, WebGPU는 오직 정점 셰이더에서 builtin(position)를 반환하는 것과
프래그먼트 셰이더에서 location(0)에 색상/값을 반환하는 것만 제대로 되면 됩니다.[1]
먼저 할 것은 스토리지 선언을 런타임에 크기가 정해지는 배열로 바꾸는 것입니다.
-@group(0) @binding(0) var<storage, read> ourStruct: OurStruct; -@group(0) @binding(1) var<storage, read> otherStruct: OtherStruct; +@group(0) @binding(0) var<storage, read> ourStructs: array<OurStruct>; +@group(0) @binding(1) var<storage, read> otherStructs: array<OtherStruct>;
그리고 셰이더에서는 이 값을 사용하도록 변경합니다.
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32,
+ @builtin(instance_index) instanceIndex: u32
) -> @builtin(position) {
let pos = array(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
+ let otherStruct = otherStructs[instanceIndex];
+ let ourStruct = ourStructs[instanceIndex];
return vec4f(
pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
}
정점 셰이더에 instanceIndex라는 새로운 매개변수를 추가하였고 @builtin(instance_index)
어트리뷰트를 추가하였는데 이는 각 "인스턴스(instance)"가 그려질 때마다 WebGPU로부터
값을 받는다는 뜻입니다.
draw를 호출할 때 두 번째 인자로 인스턴스의 개수를 넘겨줄 수 있고,
각 인스턴스가 그려질 때 처리될 인스턴스의 개수가 함수로 넘어오게 됩니다.
instanceIndex를 사용하여 구조체의 배열로부터 특정 구조체 요소를 얻을 수 있습니다.
또한 올바를 배열 요소로부터 색상값을 얻어와 프래그먼트 셰이더에서 사용해야 합니다.
프래먼트 셰이더는 @builtin(instance_index)에 접근할 수 없는데 이는 말이 안되기 때문입니다.
스테이지간 변수로 넘겨줄 수도 있지만 정점 셰이더에서 색상값을 읽어와 전달하는 것이 더 일반적입니다.
이를 위해서 스테이지간 변수에 관한 글에서처럼 새로운 구조체를 사용할 것입니다.
+struct VSOutput {
+ @builtin(position) position: vec4f,
+ @location(0) color: vec4f,
+}
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32,
@builtin(instance_index) instanceIndex: u32
-) -> @builtin(position) vec4f {
+) -> VSOutput {
let pos = array(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
let otherStruct = otherStructs[instanceIndex];
let ourStruct = ourStructs[instanceIndex];
- return vec4f(
- pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
+ var vsOut: VSOutput;
+ vsOut.position = vec4f(
+ pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
+ vsOut.color = ourStruct.color;
+ return vsOut;
}
-@fragment fn fs() -> @location(0) vec4f {
- return ourStruct.color;
+@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
+ return vsOut.color;
}
이제 WGSL 셰이더를 수정했으니 자바스크립트도 갱신해 봅시다.
아래와 같습니다.
const kNumObjects = 100;
const objectInfos = [];
// 두 개의 스토리지 버퍼를 만듬
const staticUnitSize =
4 * 4 + // color는 4개의 32비트 부동소수점 (각각 4바이트)
2 * 4 + // offset은 2개의 32비트 부동소수점 (각각 4바이트)
2 * 4; // padding
const changingUnitSize =
2 * 4; // scale은 2개의 32비트 부동소수점 (각각 4바이트)
const staticStorageBufferSize = staticUnitSize * kNumObjects;
const changingStorageBufferSize = changingUnitSize * kNumObjects;
const staticStorageBuffer = device.createBuffer({
label: 'static storage for objects',
size: staticStorageBufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const changingStorageBuffer = device.createBuffer({
label: 'changing storage for objects',
size: changingStorageBufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
// float32 기준의 각 uniform에 대한 오프셋
const kColorOffset = 0;
const kOffsetOffset = 4;
const kScaleOffset = 0;
{
const staticStorageValues = new Float32Array(staticStorageBufferSize / 4);
for (let i = 0; i < kNumObjects; ++i) {
const staticOffset = i * (staticUnitSize / 4);
// 이 값들은 한 번만 설정하므로 여기서 설정
staticStorageValues.set([rand(), rand(), rand(), 1], staticOffset + kColorOffset); // set the color
staticStorageValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], staticOffset + kOffsetOffset); // set the offset
objectInfos.push({
scale: rand(0.2, 0.5),
});
}
device.queue.writeBuffer(staticStorageBuffer, 0, staticStorageValues);
}
// changingStorageBuffer의 값을 갱신하기 위해 사용할 수 있는 typed array
const storageValues = new Float32Array(changingStorageBufferSize / 4);
const bindGroup = device.createBindGroup({
label: 'bind group for objects',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: staticStorageBuffer }},
{ binding: 1, resource: { buffer: changingStorageBuffer }},
],
});
위에서 우리는 두 개의 스토리지 버퍼를 만들었습니다.
하나는 OurStruct를 위한 배열이고 하나는 OtherStruct를 위한 배열입니다.
그리고 OurStuct를 위한 배열에 offset과 color값들을 채우고
그 데이터를 staticStorageBuffer에 업로드 하였습니다.
두 버퍼를 참조하는 하나의 바인드그룹만 생성하였습니다.
새로운 렌더링 코드는 아래와 같습니다.
function render() {
// 캔버스 컨텍스트로부터 현재 텍스처를 얻어오고
// 이를 렌더링을 수행할 텍스처로 설정
renderPassDescriptor.colorAttachments[0].view =
context.getCurrentTexture().createView();
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
// 자바스크립트의 Float32Array로 uniform 값을 설정
const aspect = canvas.width / canvas.height;
- for (const {scale, bindGroup, uniformBuffer, uniformValues} of objectInfos) {
- uniformValues.set([scale / aspect, scale], kScaleOffset); // set the scale
- device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
-
- pass.setBindGroup(0, bindGroup);
- pass.draw(3); // call our vertex shader 3 times
- }
+ // 각 물체에 대한 scale을 설정
+ objectInfos.forEach(({scale}, ndx) => {
+ const offset = ndx * (changingUnitSize / 4);
+ storageValues.set([scale / aspect, scale], offset + kScaleOffset); // set the scale
+ });
+ // 모든 scale값을 한번에 업로드
+ device.queue.writeBuffer(changingStorageBuffer, 0, storageValues);
+
+ pass.setBindGroup(0, bindGroup);
+ pass.draw(3, kNumObjects); // 각 인스턴스에 대해 정점 셰이더를 세 번 호출
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
위 코드는 kNumObjects개의 인스턴스를 그립니다.
각 인스턴스마다 WebGPU는 정점 셰이더를 세 번씩 호출하는데 이 때 vertex_index는 0, 1, 2로, instance_index는 0 ~ kNumObjects-1로 설정됩니다.
각각의 scale, color, offset 값을 갖는 100개의 삼각형 모두를 한 번의 드로우 콜로 그릴 수 있게 되었습니다. 같은 물체에 대한 아주 많은 인스턴스를 그리고 싶은 상황에서는 이러한 방법이 하나의 옵션이 될 수 있습니다.
여기까지는 셰이더에서 하드 코딩을 통해 직접 삼각형을 명시해 주었습니다.
정점 데이터를 저장하는 용도로 스토리지 버퍼를 쓰는 것이 가능합니다.
위 예제에서 현재 스토리지 버퍼를 instance_index로 인덱싱했던 것처럼,
스토리지 버퍼를 vertex_index로 인덱싱하여 정점 데이터를 얻어올 수 있습니다.
한번 해 보죠!
struct OurStruct {
color: vec4f,
offset: vec2f,
};
struct OtherStruct {
scale: vec2f,
};
+struct Vertex {
+ position: vec2f,
+};
struct VSOutput {
@builtin(position) position: vec4f,
@location(0) color: vec4f,
};
@group(0) @binding(0) var<storage, read> ourStructs: array<OurStruct>;
@group(0) @binding(1) var<storage, read> otherStructs: array<OtherStruct>;
+@group(0) @binding(2) var<storage, read> pos: array<Vertex>;
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32,
@builtin(instance_index) instanceIndex: u32
) -> VSOutput {
- let pos = array(
- vec2f( 0.0, 0.5), // top center
- vec2f(-0.5, -0.5), // bottom left
- vec2f( 0.5, -0.5) // bottom right
- );
let otherStruct = otherStructs[instanceIndex];
let ourStruct = ourStructs[instanceIndex];
var vsOut: VSOutput;
vsOut.position = vec4f(
- pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
+ pos[vertexIndex].position * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
vsOut.color = ourStruct.color;
return vsOut;
}
@fragment fn fs(vsOut: VSOutput) -> @location(0) vec4f {
return vsOut.color;
}
이제 정점 데이터를 갖는 스토리지 버퍼를 하나 더 만들어야 합니다. 먼저 정점 데이터를 생성하는 함수부터 만들어 보죠. 원을 그려봅시다.
function createCircleVertices({
radius = 1,
numSubdivisions = 24,
innerRadius = 0,
startAngle = 0,
endAngle = Math.PI * 2,
} = {}) {
// 각 subdivision은 두 개의 삼각형, 각 삼각형은 3개의 정점, 각 정점은 두 개의 값 (xy)를 가짐
const numVertices = numSubdivisions * 3 * 2;
const vertexData = new Float32Array(numSubdivisions * 2 * 3 * 2);
let offset = 0;
const addVertex = (x, y) => {
vertexData[offset++] = x;
vertexData[offset++] = y;
};
// 각 subdivision은 두 개의 삼각형
//
// 0--1 4
// | / /|
// |/ / |
// 2 3--5
for (let i = 0; i < numSubdivisions; ++i) {
const angle1 = startAngle + (i + 0) * (endAngle - startAngle) / numSubdivisions;
const angle2 = startAngle + (i + 1) * (endAngle - startAngle) / numSubdivisions;
const c1 = Math.cos(angle1);
const s1 = Math.sin(angle1);
const c2 = Math.cos(angle2);
const s2 = Math.sin(angle2);
// first triangle
addVertex(c1 * radius, s1 * radius);
addVertex(c2 * radius, s2 * radius);
addVertex(c1 * innerRadius, s1 * innerRadius);
// second triangle
addVertex(c1 * innerRadius, s1 * innerRadius);
addVertex(c2 * radius, s2 * radius);
addVertex(c2 * innerRadius, s2 * innerRadius);
}
return {
vertexData,
numVertices,
};
}
위 코드를 통해 아래와 같이 삼각형들로 이루어진 원이 만들어집니다.
이제 이 함수를 사용해 원을 그리기 위한 정점들로 스토리지 버퍼를 채울 수 있습니다.
// setup a storage buffer with vertex data
const { vertexData, numVertices } = createCircleVertices({
radius: 0.5,
innerRadius: 0.25,
});
const vertexStorageBuffer = device.createBuffer({
label: 'storage buffer vertices',
size: vertexData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexStorageBuffer, 0, vertexData);
그리고 바인드그룹 추가해야 합니다.
const bindGroup = device.createBindGroup({
label: 'bind group for objects',
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: staticStorageBuffer }},
{ binding: 1, resource: { buffer: changingStorageBuffer }},
+ { binding: 2, resource: { buffer: vertexStorageBuffer }},
],
});
마지막으로 렌더링 시점에 원을 구성하는 모든 정점을 그리도록 요청해야 합니다.
- pass.draw(3, kNumObjects); // call our vertex shader 3 times for several instances + pass.draw(numVertices, kNumObjects);
위에서 우리는 아래와 같은 코드를 사용하였습니다.
struct Vertex {
pos: vec2f;
};
@group(0) @binding(2) var<storage, read> pos: array<Vertex>;
구조체를 사용하지 않고 바로 vec2f를 사용하여 좀 더 쉽게 구현할 수도 있습니다.
-@group(0) @binding(2) var<storage, read> pos: array<Vertex>; +@group(0) @binding(2) var<storage, read> pos: array<vec2f>; ... -pos[vertexIndex].position * otherStruct.scale + ourStruct.offset, 0.0, 1.0); +pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
하지만 구조체를 만들면 나중에 정점별(per-vertex)데이터를 추가하는 것이 훨씬 쉬워지겠죠?
스토리지 버퍼를 사용해 정점을 넘기는 방법이 인기를 얻고 있습니다. 하지만 제가 듣기로 이러한 방법은 오래된 장치에서는 고전적인 방법에 비해 느리다고 들었고, 이 고전적인 방법은 다음 글인 정점 버퍼(vertex buffers)에서 설명하도록 하겠습니다.
색상 어태치먼트는 여러 개일 수 있고, 이 경우 location(1), location(2) 등등을 사용하여 색상/값을 반환해야 합니다. ↩︎