WebGL 방식의 고전 GPGPU (three.js)

이제는 WebGPU로 인해 고전이 되어버린 three.js에서의 WebGL에서의 GPGPU 프로그래밍에 대한 코드를 정리한다. 고전이긴 하지만 아직 웹 3D는 WebGL이 대세이니… 팔팔한 노땅의 저력인 FBO(Frame Buffer Object) 기반으로 GPGPU 코드를 정리해 보면서 추후 다시 프로젝트에 적용할(일이 있을래나 싶기도하지만) 일을 대비해 정리해 둔다.

FBO 기반의 GPGPU 프로그래밍은 그 내부 흐름이 어색하고 다소 복잡하다. WebGL은 그래픽 API로 설계된지라 GPGPU와 같은 오직 계산(Computing)을 고려하지 않았기 때문이다. 그 복잡함에 대한 설명은 생략하고…. three.js는 그 복잡한 FBO 기반의 GPGPU를 좀더 쉽게 활용할 수 있도록 GPUComputationRenderer 클래스를 AddOn으로 제공한다. (참고로 WebGPU는 GPGPU에 대한 코드가 매우 자연스럽다)

import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js'

GPUComputationRenderer 객체를 생성해 원하는 데이터의 읽고 쓰기를 모두 GPU를 통해 수행할 수 있다. 이 객체의 생성 코드 예시는 다음과 같다.

const baseGeometry = {}
baseGeometry.instance = new THREE.SphereGeometry(3)
baseGeometry.count = baseGeometry.instance.attributes.position.count

const gpgpu = {}
gpgpu.size = Math.ceil(Math.sqrt(baseGeometry.count))
gpgpu.computation = new GPUComputationRenderer(gpgpu.size, gpgpu.size, renderer)

WebGL에서의 GPGPU는 텍스쳐(정확히는 THREE.DataTexture)를 이용한다. 그러기에 텍스쳐의 크기를 정해야 하는데, 위의 코드는 baseGeometry.instance에 저장된 지오메트리의 정점들에 대한 좌표값들을 텍스쳐의 픽셀에 인코딩(맵핑) 시키고 있다. GPGPU의 텍스쳐를 구성하는 각 픽셀은 R, G, B, A로 각각은 Float 타입이다. 즉, GPGPU의 텍스쳐를 구성하는 픽셀 하나 당 총 4개의 데이터 타입을 입력할 수 있다. GLSL에서 R, G, B, A는 각각 X, Y, Z, W와 그 이름이 같다. 위의 코드는 아직 FBO에 해당하는 데이터 텍스쳐를 생성하기 전이다. 데이터 텍스쳐를 생성하는 코드는 다음과 같다.

const baseParticlesTexture = gpgpu.computation.createTexture()

이렇게 만든 데이터 텍스쳐의 구성 픽셀 하나 하나에 지오메트리의 구성 정점의 좌표를 저장하는 코드는 다음과 같다.

for (let i = 0; i < baseGeometry.count; i++) {
    const i3 = i * 3
    const i4 = i * 4

    baseParticlesTexture.image.data[i4 + 0] = baseGeometry.instance.attributes.position.array[i3 + 0]
    baseParticlesTexture.image.data[i4 + 1] = baseGeometry.instance.attributes.position.array[i3 + 1]
    baseParticlesTexture.image.data[i4 + 2] = baseGeometry.instance.attributes.position.array[i3 + 2]
    baseParticlesTexture.image.data[i4 + 3] = 0
}

위의 코드는 데이터 텍스쳐의 값에 대한 초기화 코드에 해당한다. GPU 연산에 비해 느려터진 CPU 코드를 통해 이런 초기화 코드는 한번쯤은 필요하다.

데이터 텍스쳐, 즉 FBO의 값을 읽고 쓰는 연산을 GPU에서 처리하기 위해서는 쉐이더 코드가 필요하다. 정확히는 Fragment 쉐이더이고 텍스쳐를 해당 쉐이더로 전달하기 위해 uniforms 방식이 사용된다. uniforms으로 전달될 객체 이름을 정의하는 코드는 다음과 같다.

gpgpu.particlesVariable = gpgpu.computation.addVariable('uParticles', gpgpuParticlesShader, baseParticlesTexture)
gpgpu.computation.setVariableDependencies(gpgpu.particlesVariable, [gpgpu.particlesVariable])

즉 uParticles라는 이름의 uniforms 이름으로 baseParticlesTexture 텍스쳐를 gpgpuParticlesShader라는 쉐이더로 전달한다는 것이다.

gpgpuParticlesShader는 문자열이다. 즉 쉐이더 코드이고 아래의 예시와 같다.

void main()
{
  vec2 uv = gl_FragCoord.xy/resolution.xy;

  vec4 particle = texture(uParticles, uv);
  particle.y += 0.01;
  
  gl_FragColor = particle;
}

uv를 통해 텍스쳐의 각 픽셀에 접근하기 위한 좌표를 정의하고, 해당 uv에 대한 데이터 텍스쳐(uParticles Uniforms로 지정)에서 픽셀값을 읽는다. 이렇게 읽은 픽셀값은 vec4이며 각각 r,g,b,a 또는 x,y,z,w로 읽을 수 있다. 해당 값은 변경할 수 있는데.... 변경했다면 gl_FragColor를 통해 변경된 값을 저장하면 실제로 변경된 값이 데이터 텍스쳐의 해당 위치(uv)에 맞게 저장된다.

이게 three.js에서 사용되는 WebGL 방식의 GPGPU 핵심 코드이다.

아! 지금까지 코드를 작성했고 실제 셋업의 완성을 위한 코드는 다음과 같다.

gpgpu.computation.init()

그럼... 이렇게 만든 FBO를 다른 재질의 쉐이더에서 사용하는 코드를 살펴볼 차례이다. 이를 위해서는 먼저 FBO의 각 픽셀을 참조하기 위한 인덱스가 필요하다.

const particles = {}

const particlesUvArray = new Float32Array(baseGeometry.count * 2)

for (let y = 0; y < gpgpu.size; y++) {
    for (let x = 0; x < gpgpu.size; x++) {
        const i = (y * gpgpu.size + x)
        const i2 = i * 2
        const uvX = (x + .5) / gpgpu.size
        const uvY = (y + .5) / gpgpu.size
        particlesUvArray[i2 + 0] = uvX;
        particlesUvArray[i2 + 1] = uvY;
    }
}

particles.geometry.setAttribute('aParticlesUv', new THREE.BufferAttribute(particlesUvArray, 2))

재질에 대한 FBO의 관계 설정은 다음과 같다.

particles.material = new THREE.ShaderMaterial({
    vertexShader: particlesVertexShader,
    fragmentShader: particlesFragmentShader,
    uniforms:
    {
        uSize: new THREE.Uniform(0.07),
        uResolution: new THREE.Uniform(new THREE.Vector2(sizes.width * sizes.pixelRatio, sizes.height * sizes.pixelRatio)),
        uParticlesTexture: new THREE.Uniform()
    },
    transparent: true,
    depthWrite: false,
})

위의 코드를 보면 uParticlesTexture이라는 Uniform이 보이는데, 이 Uniform 객체에 FBO를 연결한다. 해당 코드는 다음과 같다.

const tick = () => {
    gpgpu.computation.compute()
    particles.material.uniforms.uParticlesTexture.value = gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable).texture
}

tick는 프래임 렌더링마다 호출되는 함수인데, 먼저 GPGPU 연산을 통해 FBO를 업데이트하고 FBO에 연결된 재질의 uniform 객체에 FBO의 텍스쳐를 설정하면 된다.

참고로 FBO를 사용하는, 즉 연결된 재질에 대한 Vertex 쉐이더 코드의 예시는 다음과 같다. 이 코드를 통해 FBO가 어떻게 사용되는지를 엿볼 수 있다.

...

uniform sampler2D uParticlesTexture;
attribute vec2 aParticlesUv;

void main()
{
    vec4 particle = texture(uParticlesTexture, aParticlesUv);

    vec4 modelPosition = modelMatrix * vec4(particle.xyz, 1.0);
    vec4 viewPosition = viewMatrix * modelPosition;
    vec4 projectedPosition = projectionMatrix * viewPosition;
    gl_Position = projectedPosition;

    ...
}

끝으로 FBO를 업데이트 해주는 쉐이더에도 unforms 개념으로 데이터를 전달할 수 있다. uniforms 개념 형태로 전달하려면 재질을 참조해야 하는데 FBO의 해당 재질은 다음처럼 접근할 수 있다.

gpgpu.particlesVariable.material.uniforms.uTime = new THREE.Uniform(0)

이제 정말 끝.

GPU에서 처리되는 StorageBufferAttribute 생성 (Compute Shader)

수백만개의 BOIDS에 대한 데이터를 빠르게 처리하기 위한 방안 중 가장 최고의 선택은 GPU에서 처리하는 것입니다. 처리하고자 하는 BOID의 개수가 다음과 같다고 할때 최종적으로 GPU에서 읽고 쓸 수 있는 StorageBufferAttribute 객체를 생성하는 과정을 코드로 정리합니다.

const BOIDS = 9;

BOID의 위치값에 대한 데이터라고 한다면 먼저 Float32Array 객체를 생성합니다.

const positionArray = new Float32Array(BOIDS * 3);

이 배열 객체에 BOID의 정보를 채움니다. 아래는 격자 형태로 BOID들이 배치되도록 합니다.

const cellSize = 0.5;

for (let i = 0; i < BOIDS; i++) {
  const offset = i * 3;
  const row = (i % 3) - 1;
  const col = (~~(i / 3)) - 1;

  positionArray[offset + 0] = col * cellSize;
  positionArray[offset + 1] = row * cellSize;
  positionArray[offset + 2] = 0; // 이미 0으로 초기화 되어 있으므로 불필요함 코드
}

이렇게 만든 배열 객체를 StorageBufferAttribute로 생성하기 위한 코드는 다음과 같습니다.

const positionStorage = attributeArray(positionArray, "vec3");

이제 positionStorage를 통해 GPU에서 BOID의 위치를 읽고 쓸 수 있습니다.

GPU를 통해 positionStorage를 읽는 TSL 코드 예시는 다음과 같습니다.

const flockVertexTSL = Fn(() => {
  const instanceID = attribute("instanceID");
  const finalVert = modelWorldMatrix.mul(
    positionLocal.add(positionStorage.element(instanceID))
  ).toConst();
  return cameraProjectionMatrix.mul(cameraViewMatrix).mul(finalVert);
});

2번 코드는 positionStorage에 저장된 BOID 중 읽을 녀석에 대한 인덱스값입니다. 위의 경우는 별도의 attribute에 BOID의 인덱스를 저장해 사용하고 있습니다. flockVerteTSL은 재질의 vertexNode에 지정하면 됩니다.

만약 positionStorage에 저장된 BOID 들의 위치를 변경하고자 할때 GPU에서 수행할 수 있고 이를 Compute Shader를 통해 동시에 처리가 가능합니다. 아래는 해당 처리를 수행하는 GPU에서 실행되는 함수의 예시입니다.

const radius = uniform(float(0.7));
const delta = uniform(float(1));

const computePosition = Fn(() => {
  const PI2 = float(6.2832).toConst();
  const theta = PI2.div(BOIDS).toConst();
  const idx = instanceIndex.toConst();
  const posx = cos(time.add(theta.mul(idx))).mul(radius).toConst();
  const posy = sin(time.add(theta.mul(idx))).mul(radius).toConst();
  const cellSize = .5;
  const row = float(idx).mod(3.0).sub(1.0).toConst();
  const col = floor(float(idx).div(3.0)).sub(1.0).toConst();
  const v1 = vec3(posx, posy, 0).toConst();
  const v2 = vec3(col.mul(cellSize), row.mul(cellSize), 0).toConst();
  positionStorage.element(idx).assign(mix(v2, v1, delta));
})().compute(BOIDS);

위의 코드 중 instanceIndex는 처리되고 있는 BOID의 인덱스입니다. 코드의 마지막에 compute 매서드를 통해 인자로 받은 개수만큼 병렬로 동시에 실행되어질 수 있도록 해줍니다. 위에서 정의된 computePosition는 (필요하다면 필드화하여) 매 프레임마다 렌더링될 때 실행해줘야 하며 아래는 그 코드 예시입니다.

this._renderer.compute(this._computePosition);

여러 개의 텍스쳐 데이터를 한꺼번에 쉐이더로 전달하기

텍스처는 이미지 그 이상의 가치를 가진 데이터이다. 텍스쳐를 쉐이더로 넘길때 흔히 하나씩 넘기는 경우가 흔한데, 가능하다면 한꺼번에 넘기는게 속도면에서 훨씬 이득이다. 즉, 텍스쳐 배열 타입(sampler2DArray)으로 쉐이더에서 받도록 한다. 이를 위해 three.js 개발자는 다음과 같은 편리한 클래스를 제공한다.

class TextureAtlas {
  // 너무 길어서 전체 코드는 이 글 맨 아래 참조
}

사용 방법을 보자. 먼저 전달할 여러개의 이미지 파일을 위의 클래슬르 통해 불러온다.

const textures = new TextureAtlas();
diffuse.Load('myTextures', [
  './textures/a.png',
  './textures/b.png',
  './textures/c.png',
  ...
]);

textures.onLoad = () => {
  myShaderMaterial.uniforms.uniformData.value = textures.Info['myTextures'].atlas;
};

쉐이더 코드에서는 uniformData라는 이름의 uniform 데이터를 다음처럼 참조할 수 있게 된다.

uniform sampler2DArray uniformData;

main() {
  vec4 color = texture2D(uniformData, vec3(uv, 0.0));

  ...
}

texture2D의 2번째 인자가 3차원 데이터인데, 3번째 차원의 값을 통해 어떤 텍스처 데이터를 사용할지를 지정하는 인덱스이다. 0이면 첫번째 텍스쳐 데이터인 a.png, 2이면 c.png라는 식이다. 쉽죠?

너무 길어 보여주지 않았던 TextureAtlas의 전체 코드는 다음과 같다.

function _GetImageData(image) {
  const canvas = document.createElement('canvas');
  canvas.width = image.width;
  canvas.height = image.height;

  const context = canvas.getContext('2d');
  context.translate(0, image.height);
  context.scale(1, -1);
  context.drawImage(image, 0, 0);

  return context.getImageData( 0, 0, image.width, image.height );
}

class TextureAtlas {
  constructor() {
    this.create_();
    this.onLoad = () => {};
  }

  Load(atlas, names) {
    this.loadAtlas_(atlas, names);
  }

  create_() {
    this.manager_ = new THREE.LoadingManager();
    this.loader_ = new THREE.TextureLoader(this.manager_);
    this.textures_ = {};

    this.manager_.onLoad = () => {
      this.onLoad_();
    };
  }

  get Info() {
    return this.textures_;
  }

  onLoad_() {
    for (let k in this.textures_) {
      let X = null;
      let Y = null;
      const atlas = this.textures_[k];
      let data = null;

      for (let t = 0; t < atlas.textures.length; t++) {
        const loader = atlas.textures[t];
        const curData = loader();

        const h = curData.height;
        const w = curData.width;

        if (X === null) {
          X = w;
          Y = h;
          data = new Uint8Array(atlas.textures.length * 4 * X * Y);
        }

        if (w !== X || h !== Y) {
          console.error('Texture dimensions do not match');
          return;
        }
        const offset = t * (4 * w * h);

        data.set(curData.data, offset);
      }

      const diffuse = new THREE.DataArrayTexture(data, X, Y, atlas.textures.length);
      diffuse.format = THREE.RGBAFormat;
      diffuse.type = THREE.UnsignedByteType;
      diffuse.minFilter = THREE.LinearMipMapLinearFilter;
      diffuse.magFilter = THREE.LinearFilter;
      diffuse.wrapS = THREE.ClampToEdgeWrapping;
      diffuse.wrapT = THREE.ClampToEdgeWrapping;
      // diffuse.wrapS = THREE.RepeatWrapping;
      // diffuse.wrapT = THREE.RepeatWrapping;
      diffuse.generateMipmaps = true;
      diffuse.needsUpdate = true;

      atlas.atlas = diffuse;
    }

    this.onLoad();
  }

  loadType_(t) {
    if (typeof(t) == 'string') {
      const texture = this.loader_.load(t);
      return () => {
        return _GetImageData(texture.image);
      };
    } else {
      return () => {
        return t;
      };
    }
  }

  loadAtlas_(atlas, names) {
    this.textures_[atlas] = {
      textures: names.map(n => this.loadType_(n))
    };
  }
}

뭐.. 별거 없죠?

프레넬(Fresnel)

아래와 같은 glsl 코드가 있을때 …

vec3 viewDirection = normalize(vPosition - cameraPosition);
vec3 normal = normalize(vNormal);
float fresnel = ...
vec3 color = vec3(fresnel);

gl_FragColor = vec4(color, 1.0);

세번째 줄의 코드를 다음처럼 하면 …

float fresnel = pow(dot(viewDirection, normal) + 1., 10.);

또는 다음처럼 하면 …

float fresnel = pow(abs(dot(viewDirection, normal) + .3), 2.);