이 글은 셰이더에 데이터를 전달하는 다양한 방법에 대한 시리즈 중 하나입니다. 각 글은 이전 내용을 바탕으로 작성되었으므로 순서대로 읽는 것이 가장 이해하기 좋습니다.
이전 글은 스테이지간 변수에 관한 글이었습니다. 이번 글은 uniform에 관한 글입니다.
uniform은 셰이더에서 사용하는 전역 변수같은 겁니다. 셰이더를 실행하기 전에 값을 설정하고 그 값을 셰이더의 모든 반복 과정에서 사용할 수 있습니다. 그리고 GPU에게 다음 번 셰이더 실행 명령을 내릴 때에 다른 값으로 설정할 수 있습니다.
첫 번째 글에서의 삼각형 예제 코드부터 시작해서 uniform을 사용하도록 수정합니다.
const module = device.createShaderModule({
label: 'triangle shaders with uniforms',
code: /* wgsl */ `
+ struct OurStruct {
+ color: vec4f,
+ scale: vec2f,
+ offset: vec2f,
+ };
+
+ @group(0) @binding(0) var<uniform> ourStruct: OurStruct;
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
- return vec4f(pos[vertexIndex], 0.0, 1.0);
+ return vec4f(
+ pos[vertexIndex] * ourStruct.scale + ourStruct.offset, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
- return vec4f(1, 0, 0, 1);
+ return ourStruct.color;
}
`,
});
});
먼저 세 개의 멤버를 갖는 구조체를 선언합니다.
struct OurStruct {
color: vec4f,
scale: vec2f,
offset: vec2f,
};
그리고 그 구조체 타입인 uniform 변수를 선언합니다.
변수는 ourStruct이고 타입은 OurStruct입니다.
@group(0) @binding(0) var<uniform> ourStruct: OurStruct;
다음으로 정점 셰이더의 반환값을 uniform을 사용하도록 변경합니다.
@vertex fn vs(
...
) ... {
...
return vec4f(
pos[vertexIndex] * ourStruct.scale + ourStruct.offset, 0.0, 1.0);
}
정점의 위치에 scale을 곱하고 offset을 더한 것을 볼 수 있습니다. 이렇게 하면 삼각형의 크기와 위치를 설정할 수 있습니다.
또한 정점 셰이더가 uniform의 color값을 반환하도록 수정합니다.
@fragment fn fs() -> @location(0) vec4f {
return ourStruct.color;
}
이제 셰이더가 uniform을 사용하도록 설정하였으니 여기에 사용할 값을 저장할 버퍼를 GPU에 만들어야 합니다.
이제부터의 내용은 여러분이 네이티브(native) 데이터와 크기를 다루어 본 적이 없다면 배울 것이 많을 것입니다. 이는 꽤 큰 주제이므로 해당 주제에 대한 별도의 글이 있습니다. 구조체를 메모리에 어떻게 레이아웃(layout)하는지 모르면, 이 글을 읽어보고 다시 돌아오세요. 여기서는 여러분이 이 글을 읽었다고 가정하고 진행합니다.
이 글을 읽으셨으면 이제 셰이더의 구조체와 매칭되도록 버퍼에 데이터를 채울 것입니다.
먼저 버퍼를 만들고 usage 플래그를 통해 그것이 uniform을 위해 사용될 것이고 데이터를 여기에 복사하여 갱신할 것임을 명시합니다.
const uniformBufferSize =
4 * 4 + // color는 4개의 32비트 부동소수점 (각각 4바이트)
2 * 4 + // scale은 2개의 32비트 부동소수점 (각각 4바이트)
2 * 4; // offset은 2개의 32비트 부동소수점 (각각 4바이트)
const uniformBuffer = device.createBuffer({
size: uniformBufferSize,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});
그리고 자바스크립트에서 값을 저장할 TypedArray를 만듭니다.
// uniform을 위해 사용할 값을 저장할 typedarray를 자바스크립트에서 만듬 const uniformValues = new Float32Array(uniformBufferSize / 4);
그리고 구조체에서, 나중에 바뀌지 않을 두 개의 값을 채울 것입니다. 오프셋은 메모리 레이아웃에 관한 글에서 설명한 방식대로 계산합니다.
// float32 기준의 uniform 값들에 대한 오프셋 const kColorOffset = 0; const kScaleOffset = 4; const kOffsetOffset = 6; uniformValues.set([0, 1, 0, 1], kColorOffset); // color 설정 uniformValues.set([-0.5, -0.25], kOffsetOffset); // offset 설정
위에서 우리는 색상을 녹색으로 설정했습니다. 오프셋은 삼각형을 왼쪽으로 캔버스의 1/4만큼, 아래쪽으로 캔버스의 1/8만큼 이동합니다 (클립 공간은 -1에서 1 사이여서 2유닛이고, 따라서 2유닛의 1/8은 0.25임).
다음으로 첫 번째 글의 다이어그램에서 셰이더에게 버퍼에 대해 알려준 것처럼, 우리는 바인드 그룹을 만들고 해당 버퍼를 셰이더에서 설정한 것과 동일한 @binding(?)에 바인딩해야 합니다.
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: uniformBuffer },
],
});
이제 커맨드 버퍼를 제출하기 전에 uniformValues의 나머지 값을 채우고 그 값들을 GPU 버퍼에 복사할 것입니다.
이 작업은 render 함수의 가장 앞쪽에서 수행합니다.
function render() {
// 자바스크립트의 Float32Array에 uniform 값을 설정함
const aspect = canvas.width / canvas.height;
uniformValues.set([0.5 / aspect, 0.5], kScaleOffset); // scale 설정
// 이 값을 자바스크립트에서 GPU로 복사함
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
참고:
writeBuffer는 데이터를 버퍼로 복사하는 방법 중 하나입니다. 다른 방법들은 이 글에서 설명합니다.
scale은 절반 크기로 하였고 캔버스의 종횡비(aspect ratio)를 고려하였으므로 삼각형은 캔버스의 크기가 변해도 일정한 가로세로 비율을 유지할 것입니다.
마지막으로, 그리기 전에 바인드 그룹을 설정합니다.
pass.setPipeline(pipeline);
+ pass.setBindGroup(0, bindGroup);
pass.draw(3); // call our vertex shader 3 times
pass.end();
이렇게 하면 명시한대로 초록색 삼각형이 그려집니다.
이 단일 삼각형에 대해 그리기 커맨드를 실행할 때의 상태는 아래와 같습니다.
지금까지, 셰이더에서 우리가 사용한 데이터들은 하드코딩 되어 있었습니다(정점 셰이더에서의 삼각형 정점 위치와 프래그먼트 셰이더에서의 색상).
이제 셰이더에 데이터를 전달할 수 있게 되었으니 다양한 데이터를 전달하여 draw를 호출할 수 있을 겁니다.
버퍼를 갱신하여 서로다른 offset, scale, color를 사용하여 다른 위치에 그릴 수 있습니다. 하지만 중요한 것은 우리의 커맨드는 커맨드 버퍼에 들어가고, 제출하기 전에는 실행되지 않는다는 점입니다. 따라서 아래와 같이는 할 수 없습니다.
// BAD!
for (let x = -1; x < 1; x += 0.1) {
uniformValues.set([x, x], kOffsetOffset);
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
pass.draw(3);
}
pass.end();
// 인코딩을 끝내고 커맨드를 제출
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
왜냐하면 위에서 볼 수 있는 것처럼 device.queue.xxx는 "queue"에 작업을 하는 함수지만 pass.xxx 함수는 커맨드를 커맨드 버퍼에 인코딩할 뿐입니다.
실제로 submit으로 커맨드 버퍼를 제출하면 마지막으로 쓰여진 값만이 버퍼에 적용됩니다.
아래와 같이 수정할 수도 있습니다.
// BAD! Slow!
for (let x = -1; x < 1; x += 0.1) {
uniformValues.set([x, 0], kOffsetOffset);
device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass(renderPassDescriptor);
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
// 인코딩을 끝내고 커맨드를 제출
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
위의 코드는 버퍼 하나를 갱신하고 커맨드 버퍼를 하나 생성한 뒤, 그리기 한 번을 수행하기 위해 커맨드를 추가하고 제출하여 커맨드 버퍼를 실행하고 있습니다. 이건 동작하긴 하지만 여러 이유로 인해 느립니다. 가장 큰 원인은 하나의 커맨드 버퍼에서 많은 작업을 하는 것이 더 좋다는 사실입니다.
그러니 우리는 그리기를 원하는 상태 하나당 하나의 uniform 버퍼를 만들 것입니다. 그리고 버퍼는 바인드 그룹을 통해 간접적으로 사용되니 그리기를 원하는 것 하나마다 하나의 바인드 그룹을 만듭니다. 그리고 모든 그리기를 원하는 것을 하나의 커맨드 버퍼에 넣을 겁니다.
한 번 해 보죠.
먼저 랜덤 함수를 만듭니다.
// [min and max) 사이의 무작위 값을 반환
// 하나의 인자를 넣으면 [0 to min)
// 인자가 없으면 [0 to 1)
const rand = (min, max) => {
if (min === undefined) {
min = 0;
max = 1;
} else if (max === undefined) {
max = min;
min = 0;
}
return min + Math.random() * (max - min);
};
이제 여러 색상과 오프셋을 갖는 버퍼들을 설정해서 여러 물체를 그릴 수 있도록 합시다.
// float32 기준의 uniform 값들에 대한 오프셋
const kColorOffset = 0;
const kScaleOffset = 4;
const kOffsetOffset = 6;
+ const kNumObjects = 100;
+ const objectInfos = [];
+
+ for (let i = 0; i < kNumObjects; ++i) {
+ const uniformBuffer = device.createBuffer({
+ label: `uniforms for obj: ${i}`,
+ size: uniformBufferSize,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ // uniform을 위해 사용할 값을 저장할 typedarray를 자바스크립트에서 만듬
+ const uniformValues = new Float32Array(uniformBufferSize / 4);
- uniformValues.set([0, 1, 0, 1], kColorOffset); // set the color
- uniformValues.set([-0.5, -0.25], kOffsetOffset); // set the offset
+ uniformValues.set([rand(), rand(), rand(), 1], kColorOffset); // set the color
+ uniformValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], kOffsetOffset); // set the offset
+
+ const bindGroup = device.createBindGroup({
+ label: `bind group for obj: ${i}`,
+ layout: pipeline.getBindGroupLayout(0),
+ entries: [
+ { binding: 0, resource: uniformBuffer },
+ ],
+ });
+
+ objectInfos.push({
+ scale: rand(0.2, 0.5),
+ uniformBuffer,
+ uniformValues,
+ bindGroup,
+ });
+ }
아직 버퍼에 값을 넣지 않았는데, 이는 캔버스의 종횡비를 고려하고 싶은데 렌더링을 하기 전까지는 종횡비를 알 수 없기 때문입니다.
렌더링 시점에는 모든 버퍼를 올바른 종횡비로 scale을 조정하여 갱신합니다.
function render() {
- // Set the uniform values in our JavaScript side Float32Array
- const aspect = canvas.width / canvas.height;
- uniformValues.set([0.5 / aspect, 0.5], kScaleOffset); // set the scale
-
- // copy the values from JavaScript to the GPU
- device.queue.writeBuffer(uniformBuffer, 0, uniformValues);
// 캔버스 컨텍스트에서 텍스처를 얻고,
// 이를 렌더링할 텍스처로 설정함
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
+ }
pass.end();
const commandBuffer = encoder.finish();
device.queue.submit([commandBuffer]);
}
여기서도 encoder와 pass객체는 커맨드를 커맨드 버퍼에 인코딩할 뿐임을 기억하세요.
따라서 render 함수가 끝나면 아래와 같은 커맨드들을 실행한 것과 같습니다.
device.queue.writeBuffer(...) // uniform 버퍼 0에 물체 0 데이터 갱신 device.queue.writeBuffer(...) // uniform 버퍼 1에 물체 1 데이터 갱신 device.queue.writeBuffer(...) // uniform 버퍼 2에 물체 2 데이터 갱신 device.queue.writeBuffer(...) // uniform 버퍼 3에 물체 3 데이터 갱신 ... // 그리기를 100번 수행하는 커맨드를 실행하는데, 각각은 각자의 uniform 버퍼로 실행됨 device.queue.submit([commandBuffer]);
결과는 아래와 같습니다.
이왕 시작한 김에 한 가지 더 이야기 해보겠습니다.
셰이더에서 여러 uniform 버퍼를 참조할 수 있습니다.
우리 예제에서는 그리기를 수행할 때마다 scale을 갱신하고 writeBuffer를 통해 uniformValues를 각각 해당하는 uniform 버퍼에 갱신하였습니다.
하지만 scale만 갱신되고 color와 offset은 아니기 때문에 이 값들을 업로드하는 낭비가 발생하고 있습니다.
그리기를 수행할 때 갱신되는 uniform과 고정된 uniform을 분리할 수 있습니다.
const module = device.createShaderModule({
code: /* wgsl */ `
struct OurStruct {
color: vec4f,
- scale: vec2f,
offset: vec2f,
};
+ struct OtherStruct {
+ scale: vec2f,
+ };
@group(0) @binding(0) var<uniform> ourStruct: OurStruct;
+ @group(0) @binding(1) var<uniform> otherStruct: OtherStruct;
@vertex fn vs(
@builtin(vertex_index) vertexIndex : u32
) -> @builtin(position) vec4f {
let pos = array(
vec2f( 0.0, 0.5), // top center
vec2f(-0.5, -0.5), // bottom left
vec2f( 0.5, -0.5) // bottom right
);
return vec4f(
- pos[vertexIndex] * ourStruct.scale + ourStruct.offset, 0.0, 1.0);
+ pos[vertexIndex] * otherStruct.scale + ourStruct.offset, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4f {
return ourStruct.color;
}
`,
});
그러면 그리는 물체 하나당 두 개의 uniform 버퍼가 필요합니다.
- // create a buffer for the uniform values
- const uniformBufferSize =
- 4 * 4 + // color is 4 32bit floats (4bytes each)
- 2 * 4 + // scale is 2 32bit floats (4bytes each)
- 2 * 4; // offset is 2 32bit floats (4bytes each)
- // offsets to the various uniform values in float32 indices
- const kColorOffset = 0;
- const kScaleOffset = 4;
- const kOffsetOffset = 6;
+ // create 2 buffers for the uniform values
+ const staticUniformBufferSize =
+ 4 * 4 + // color is 4 32bit floats (4bytes each)
+ 2 * 4 + // offset is 2 32bit floats (4bytes each)
+ 2 * 4; // padding
+ const uniformBufferSize =
+ 2 * 4; // scale is 2 32bit floats (4bytes each)
+
+ // offsets to the various uniform values in float32 indices
+ const kColorOffset = 0;
+ const kOffsetOffset = 4;
+
+ const kScaleOffset = 0;
const kNumObjects = 100;
const objectInfos = [];
for (let i = 0; i < kNumObjects; ++i) {
+ const staticUniformBuffer = device.createBuffer({
+ label: `static uniforms for obj: ${i}`,
+ size: staticUniformBufferSize,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
+
+ // These are only set once so set them now
+ {
- const uniformValues = new Float32Array(uniformBufferSize / 4);
+ const uniformValues = new Float32Array(staticUniformBufferSize / 4);
uniformValues.set([rand(), rand(), rand(), 1], kColorOffset); // set the color
uniformValues.set([rand(-0.9, 0.9), rand(-0.9, 0.9)], kOffsetOffset); // set the offset
+ // copy these values to the GPU
+ device.queue.writeBuffer(staticUniformBuffer, 0, uniformValues);
}
+ // create a typedarray to hold the values for the uniforms in JavaScript
+ const uniformValues = new Float32Array(uniformBufferSize / 4);
+ const uniformBuffer = device.createBuffer({
+ label: `changing uniforms for obj: ${i}`,
+ size: uniformBufferSize,
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
+ });
const bindGroup = device.createBindGroup({
label: `bind group for obj: ${i}`,
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: staticUniformBuffer },
+ { binding: 1, resource: uniformBuffer },
],
});
objectInfos.push({
scale: rand(0.2, 0.5),
uniformBuffer,
uniformValues,
bindGroup,
});
}
렌더링 코드는 변한 것이 없습니다. 각 물체의 바인드 그룹은 각 물체에 대한 두 개의 uniform 버퍼에 대한 참조를 가지고 있습니다.
앞에서와 동일하게 scale을 업데이트하고 있지만 device.queue.writeBuffer를 호출해서 uniform 버퍼를 업데이트 할 때에만 scale을 업로드합니다.
이전에는 각 객체에 대해 color + offset + scale를 업로드 하였습니다.
이 간단한 예제에서 uniform 버퍼를 구분하는 것은 과할 수도 있지만, 언제 무엇이 변하는지에 따라 구분하는 것이 일반적입니다. 예를 들어 공유되는 행렬에 대한 uniform 버퍼가 있습니다. 투영(project), 뷰(view)/카메라 행렬이 있을 수 있겠죠. 대부분 이 값들은 우리가 그리고자 하는 대상에 대해 동일한 값을 사용하기 때문에 하나의 버퍼를 만들어서 모든 객체가 동일한 uniform 버퍼를 사용하게 하면 됩니다.
셰이더가 참조하는 또다른 uniform 버퍼는 별도로 구분된, 각 객체에 대해 다른 값을 가지는 world/model 행렬이나 노멀(normal) 행렬이 될 것입니다.
또 다른 uniform 버퍼는 머티리얼(material) 세팅값을 가지고 있을 수 있습니다. 이러한 세팅값은 여러 객체에서 공유될 것입니다.
이러한 내용은 3D 그리기를 다룰 때 많이 활용할 것입니다.
다음은 스토리지 버퍼(storage buffer)입니다.