목차

webgpufundamentals.org

Fix, Fork, Contribute

WebGPU Uniforms

이전 글은 스테이지간 변수에 관한 글이었습니다. 이번 글은 uniform에 관한 글입니다.

uniform은 셰이더에서 사용하는 전역 변수같은 겁니다. 셰이더를 실행하기 전에 값을 설정하고 그 값을 셰이더의 모든 반복 과정에서 사용할 수 있습니다. 그리고 GPU에게 다음 번 셰이더 실행 명령을 내릴 때에 다른 값으로 설정할 수 있습니다.

첫 번째 글에서의 삼각형 예제 코드부터 시작해서 uniform을 사용하도록 수정합니다.

  const module = device.createShaderModule({
    label: 'triangle shaders with uniforms',
    code: `
+      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]);
  }

여기서도 encoderpass객체는 커맨드를 커맨드 버퍼에 인코딩할 뿐임을 기억하세요. 따라서 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: `
      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(uniformBuffer, 0, uniformValues);
+      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)입니다.

질문이 있나요? Stack Overflow에 물어보세요.
제안 / 요청 사항 / 이슈 / 버그
comments powered by Disqus