목차

webgpufundamentals.org

Fix, Fork, Contribute

WebGPU 데이터 복사하기

지금까지 대부분의 글에서 버퍼에 데이터를 넣기 위해서 writeBuffer를 사용하고 텍스처에 데이터를 넣기 위해서 writeTexture를 사용했습니다. 버퍼나 텍스처에 데이터를 전달하는 다양한 방법이 있습니다.

writeBuffer

writeBuffer는 자바스크립트의 TypedArray 또는 ArrayBuffer로부터 버퍼로 데이터를 복사합니다. 이는 버퍼로 데이터를 전달하는 가장 직관적인 방법입니다.

writeBuffer는 아래와 같은 포맷을 따릅니다.

device.queue.writeBuffer(
  destBuffer,  // 데이터를 쓸 대상 버퍼
  destOffset,  // 대상의 어디에서부터 데이터를 쓰기 시작할 것인지
  srcData,     // typedArray 또는 arrayBuffer
  srcOffset?,  // srcData의 어떤 **요소(element)**부터 복사할 것인지 오프셋
  size?,       // 복사항 srcData의 **요소**단위 크기
)

srcOffset이 전달되지 않았으면 0을 사용합니다. size가 전달되지 않았다면 srcData의 크기가 사용됩니다.

중요: srcOffsetsizesrcData의 요소 단위입니다.

다시 말해,

device.queue.writeBuffer(someBuffer, someOffset, someFloat32Array, 6, 7);

위 코드는 float32의 6번째부터 7개의 데이터를 복사합니다. 다른 방식으로 말하자면 someFloat32Array의 뷰(view)로 arrayBuffer의 24바이트 위치부터 시작해서 28 바이트를 복사합니다.

writeTexture

writeTexture는 자바스크립트의 TypedArray 또는 ArrayBuffer로부터 텍스처로 데이터를 복사합니다.

writeTexture는 아래와 같은 시그니처(signature)를 갖습니다.

device.writeTexture(
  // 복사 대상의 세부사항
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 소스 데이터
  srcData,

  // 소스 데이터 세부사항
  { offset: 0, bytesPerRow, rowsPerImage },

  // 크기:
  [width, height, depthOrArrayLayers]
);

주의 사항으로:

  • textureGPUTextureUsage.COPY_DST usage가 있어야 합니다.

  • mipLevel, origin, aspect 는 모두 기본값이 있어서 생략하는 경우가 많습니다.

  • bytesPerRow: 이 값은 다음 *블럭 행(block row)*의 데이터를 얻기 위해 알마나 많은 바이트를 건너가야 하는지에 대한 값입니다.

    이는 하나 이상의 블럭 행을 복사할 떄 필요합니다. 거의 대분 하나 이상의 블럭 행을 복사하기 때문에 거의 대부분의 경우에 값을 명시해야 합니다.

  • rowsPerImage: 이 값은 하나의 이미지에서부터 다음 이미지까지 얼마나 많은 블럭 행을 건너가야 하는지에 대한 값입니다.

    이는 하나 이상의 레이어를 복사할 때 필요합니다. 다시 말해, 크기 인자의 depthOrArrayLayers가 1 이상이라면 이 값을 명시해야 합니다.

복사는 아래와 같은 방식으로 동작한다고 생각할 수 있습니다.

// pseudo code
const [x, y, z] = origin || [0, 0, 0];
const [blockWidth, blockHeight] = getBlockSizeForTextureFormat(texture.format);

const blocksAcross = width / blockWidth;
const blocksDown = height / blockHeight;

for (layer = 0; layer < depthOrArrayLayers; layer) {
  for (row = 0; row < blocksDown; ++row) {
    const start = offset + (layer * rowsPerImage + row) * bytesPerRow;
    copyRowToTexture(
      texture, // texture to copy to
      x,
      y + row,
      z + layer, // where in texture to copy to
      srcDataAsBytes + start,
      bytesPerRow
    );
  }
}

블럭 행(block row)

텍스처는 블럭과 같은 구조입니다. 대부분의 일반적인 텍스처는 블럭 행과 열이 모두 1입니다. 압축된(compressed) 텍스처에서는 상황이 변합니다. 예를들어 bc1-rgba-unorm 포맷은 블럭의 너비와 높이가 4입니다. 즉 width를 8로, 높이를 12로 설정했다면 여섯 개의 블럭만 복사됩니다. 첫 번째와 두 번째 행에서는 2개씩, 세 번째 행에서 2개가 복사됩니다.

압축된 텍스처에서는 크기와 원점(origin)이 블럭의 크기와 정렬되어야 합니다.

주의: WebGPU에서 (GPUExtent3D로 정의된)크기를 입력받는 경우 1~3개의 숫자로 이루어진 배열이거나, 1~3개의 속성을 갖는 객체입니다. heightdepthOrArrayLayers의 기본값은 1입니다. 따라서,

  • [2] width = 2, height = 1, depthOrArrayLayers = 1
  • [2, 3] width = 2, height = 3, depthOrArrayLayers = 1
  • [2, 3, 4] width = 2, height = 3, depthOrArrayLayers = 4
  • { width: 2 } width = 2, height = 1, depthOrArrayLayers = 1
  • { width: 2, height: 3 } width = 2, height = 3, depthOrArrayLayers = 1
  • { width: 2, height: 3, depthOrArrayLayers: 4 } width = 2, height = 3, depthOrArrayLayers = 4

같은 방식으로 (GPUOrigin3D로 정의된) 원점에 대해서는 3개의 숫자로 이루어진 배열이거나 x, y, z 속성을 갖는 객체입니다. 기본값은 모두 0입니다. 따라서,

  • [5] an origin where x = 5, y = 0, z = 0
  • [5, 6] an origin where x = 5, y = 6, z = 0
  • [5, 6, 7] an origin where x = 5, y = 6, z = 7
  • { x: 5 } an origin where x = 5, y = 0, z = 0
  • { x: 5, y: 6 } an origin where x = 5, y = 6, z = 0
  • { x: 5, y: 6, z: 7 } an origin where x = 5, y = 6, z = 7
  • aspect는 깊이-스텐실(stencil) 포맷으로 데이터를 복사할 때만 관여합니다. 각 aspect마다 한 번씩 데이터를 복사해야 하며 depth-only 또는 stencil-only를 사용해야 합니다.

copyBufferToBuffer

copyBufferToBuffer는 이름 그대로 하나의 버퍼에서 다른 버퍼로 데이터를 복사합니다.

시그니처는:

encoder.copyBufferToBuffer(
  source, // 복사할 값을 얻어올 버퍼
  sourceOffset, // 어느 위치부터 가져올 것인지
  dest, // 복사할 대상 버퍼
  destOffset, // 어느 위치부터 넣을 것인지
  size // 몇 바이트를 복사할 것인지
);
  • sourceGPUBufferUsage.COPY_SRC여야 합니다.
  • destGPUBufferUsage.COPY_DST여야 합니다.
  • size는 4의 배수여야 합니다.

copyBufferToTexture

copyBufferToTexture는 이름 그대로 버퍼에서 텍스처로 데이터를 복사합니다.

시그니처는:

encode.copyBufferToTexture(
  // 소스 버퍼 세부사항
  { buffer, offset: 0, bytesPerRow, rowsPerImage },

  // 대상 텍스처 세부사항
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 크기:
  [width, height, depthOrArrayLayers]
);

writeTexture와 거의 동일한 매개변수를 갖습니다. 가장 큰 차이는 bytesPerRow이며 256의 배수여야 합니다!!

  • textureGPUTextureUsage.COPY_DST여야 합니다.
  • bufferGPUBufferUsage.COPY_SRC여야 합니다.

copyTextureToBuffer

copyTextureToBuffer는 이름 그대로 텍스처에서 버퍼로 데이터를 복사합니다.

시그니처는:

encode.copyTextureToBuffer(
  // 소스 텍스처 세부사항
  { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 대상 버퍼 세부사항
  { buffer, offset: 0, bytesPerRow, rowsPerImage },

  // 크기:
  [width, height, depthOrArrayLayers]
);

이는 copyBufferToTexture와 비슷한 매개변수를 가지며, 텍스처(여기서는 소스)와 버퍼(여기서는 대상)가 바뀐 형태입니다. copyTextureToBuffer에서처럼 bytesPerRow256의 배수여야 합니다!!

  • textureGPUTextureUsage.COPY_SRC여야 합니다.
  • bufferGPUBufferUsage.COPY_DST여야 합니다.

copyTextureToTexture

copyTextureToTexture는 텍스처의 일부분을 다른 텍스처에 복사합니다.

두 텍스처는 모두 같은 포맷이거나 접미어인 '-srgb'만 달라야 합니다.

시그니처는:

encode.copyTextureToBuffer(
  // 소스 텍스처 세부사항
  src: { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 대상 텍스처 세부사항
  dst: { texture, mipLevel: 0, origin: [0, 0, 0], aspect: "all" },

  // 크기:
  [ width, height, depthOrArrayLayers ]
);
  • src.textureGPUTextureUsage.COPY_SRC여야 합니다.
  • dst.textureGPUTextureUsage.COPY_DST여야 합니다.
  • width는 블럭 너비의 배수여야 합니다.
  • height는 블럭 높이의 배수여야 합니다.
  • src.origin[0] 또는 .x 는 너비의 배수여야 합니다.
  • src.origin[1] 또는 .y 는 높이의 배수여야 합니다.
  • dst.origin[0] 또는 .x 는 너비의 배수여야 합니다.
  • dst.origin[1] 또는 .y 는 높이의 배수여야 합니다.

셰이더

셰이더는 스토리지 버퍼, 스토리지 텍스처에 값을 쓸 수 있으며 간접적으로 텍스처에 렌더링을 할 수 있습니다. 이러한 방법들이 버퍼나 텍스처에 값을 쓰는 방법입니다. 즉 데이터를 생성하는 셰이더를 만들 수 있습니다.

버퍼 맵핑(mapping)

버퍼를 맵핑할 수 있습니다. 버퍼를 맵핑한다는 뜻은 자바스크립트에서 값을 읽거나 쓸 수 있도록 한다는 뜻입니다. 최소한 WebGPU의 버전 1에서 맵핑 가능한(mappable) 버퍼에는 심각한 제약사항이 있습니다. 이는 맵핑 가능한 버퍼가 데이터를 복사항 임시 공간으로만 사용 가능한 점입니다. 맵핑 가능한 버퍼는 다른 종류의 버퍼(Uniform 버퍼, 정점 버퍼, 인덱스 버퍼, 스토리지 버퍼 등)로 사용할 수 없습니다. [1]

맵핑가능한 버퍼는 두 종류의 사용법 플래그의 조합으로 만들 수 있습니다.

  • GPUBufferUsage.MAP_READ | GPU_BufferUsage.COPY_DST

    다른 버퍼나 텍스처로부터 데이터를 복사하는 커맨드를 사용할 수 있는 버퍼로, 맵핑하여 자바스크립트로부터 데이터를 읽을 수 있습니다.

  • GPUBufferUsage.MAP_WRITE | GPU_BufferUsage.COPY_SRC

    자바스크립트에서 맵핑하여 데이터를 넣을 수 있는 버퍼입니다. 그리고 언맵핑(unmap)하여 위에서 설명한 복사 커맨드로 그 내용을 다른 버퍼나 텍스처에 복사할 수 있습니다.

버퍼의 맵핑 과정은 비동기적입니다. offsetsize를 바이트 단위로 하여 buffer.mapAsync(mode, offset = 0, size?)를 호출할 수 있습니다. size가 명시되어 있지 않으면 전체 버퍼 크기를 의미합니다. modeGPUMapMode.READ 또는 GPUMapMode.WRITE여야 하며 당연히 버퍼를 생성할 때 사용한 MAP_ 사용법 플래그와 일치해야 합니다.

mapAsync는 프라미스(Promise)를 반환합니다. 프라미스가 해소(resolve)되면 버퍼는 맵핑 가능한 상태가 됩니다. 이후에 buffer.getMappedRange(offset = 0, size?)를 호출해서 버퍼의 일부 또는 전체를 볼 수 있으며, 여기서 offset은 맵핑한 버퍼의 일부분에 대한 바이트 오프셋입니다. getMappedRangeArrayBuffer를 반환하니 이 값을 사용하기 위해서는 일반적으로 이 값을 가지고 TypedArray를 만들게 됩니다.

아래는 버퍼 맵핑의 한 예입니다.

const buffer = device.createBuffer({
  size: 1024,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});

// map the entire buffer
await buffer.mapAsync(GPUMapMode.READ);

// get the entire buffer
const f32 = new Float32Array(buffer.getMappedRange())

...

buffer.unmap();

Note: 한 번 맵핑이 되면, 버퍼는 unmap을 호출하기 전까지는 WebGPU에서 사용 불가능한 상태가 됩니다. unmap을 호출한 순간 버퍼는 자바스크립트에서 사라집니다. 다시 말해 위 예제를 기반으로 설명하자면 아래와 같습니다.

const f32 = new Float32Array(buffer.getMappedRange());

f32[0] = 123;
console.log(f32[0]); // prints 123

buffer.unmap();

console.log(f32[0]); // prints undefined

데이터를 읽기 위해 버퍼를 맵핑하는 예제는 이미 본 적이 있습니다. 첫 번째 글에서 스토리지 버퍼의 숫자를 두 배로 늘리고 이를 맵핑 가능한 버퍼에 복사하고 그 결과를 읽기 위해서 맵핑하였습니다.

다른 예시는 컴퓨트 셰이더 기본에 있는데, 컴퓨트 셰이더의 여러 @builtin 값을 스토리지 버퍼에 맵핑하였습니다. 그리고 그 결과를 맵핑 가능한 버퍼에 복사하고 맵핑하여 값을 읽어옵니다.

mappedAtCreation

mappedAtCreation: true은 버퍼를 생성할 때 추가할 수 있는 플래그입니다. 이 경우 버퍼는 GPUBufferUsage.COPY_DSTGPUBufferUsage.MAP_WRITE 사용법 플래스를 명시할 필요가 없어집니다.

이는 버퍼 생성시에 데이터를 넣을 수 있도록 하는 특별한 플래그입니다. 버퍼를 생성할 때 mappedAtCreation: true를 추가할 수 있습니다. 버퍼가 생성되면, 이미 값을 쓸 수 있도록 맵핑 가능한 상태가 됩니다. 그 예로:

const buffer = device.createBuffer({
  size: 16,
  usage: GPUBufferUsage.UNIFORM,
  mappedAtCreation: true,
});
const arrayBuffer = buffer.getMappedRange(0, buffer.size);
const f32 = new Float32Array(arrayBuffer);
f32.set([1, 2, 3, 4]);
buffer.unmap();

좀더 간결하게는,

const buffer = device.createBuffer({
  size: 16,
  usage: GPUBufferUsage.UNIFORM,
  mappedAtCreation: true,
});
new Float32Array(buffer.getMappedRange(0, buffer.size)).set([1, 2, 3, 4]);
buffer.unmap();

맵핑가능한 버퍼의 효율적인 사용

위에서 우리는 버퍼 맵핑이 비동기적이라고 했습니다. 즉 mapAsync를 호출하여 버퍼를 맵핑하기를 요청하는 시점부터, 맵핑이 되어 getMappedRange를 호출할 수 있게되는 시점까지의 시간이 미정이라는 뜻입니다.

이를 해결하는 일반적인 방법은 몇몇 버퍼들을 항상 맵핑 상태로 두는 것입니다. 그러면 이미 맵핑이 되어 있어서 바로 사용 가능하게 됩니다. 사용한 후에 언맵핑을 하고, 어떤 커맨드든 해당 버퍼를 사용하는 커맨드를 제출하고 나면 다시 맵핑하도록 요청합니다. 프라미스가 해소되면 이를 다시 이미 맵핑된 버퍼 풀(pool)로 되돌립니다. 맵핑 가능한 버퍼가 필요한데 사용할 수 있는게 없으면 새로운 버퍼를 만들어 풀에 넣으면 됩니다.

TBD: Example


  1. mappedAtCreation: true로 설정할 때는 예외인데, mappedAtCreation를 참고하세요. ↩︎

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