이전 글은 스테이지간 변수에 관한 글이었습니다. 이번 글은 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: { buffer: 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: { buffer: 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: { buffer: staticUniformBuffer }},
+ { binding: 1, resource: { buffer: 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)입니다.