WebGPU 방식의 포인트 렌더링

WebGL은 결국 WebGPU로 대체될 기술이기에 그간 개발된 프로젝트를 WebGPU 기반으로 변경하고 있습니다. 다행히 많은 코드가 WebGL과 WebGPU에서도 동일하게 작동하기는 하지만 제대로 작동하지 않는 코드도 상당합니다. 특히 재질과 관련된 부분이 문제고 이는 재질이 쉐이더와 직접적으로 연결되어 있기 때문입니다. WebGL과 WebGPU에서 사용하는 쉐이더는 그 언어부터가 다른데 각각 GLSL과 WGSL입니다. 이 글은 WebGL에서는 정상적으로 작동하던 포인트 레더링 코드가 WebGPU에서는 더 이상 정상적으로 작동하지 않는 부분에 대한 정리입니다. 바로 포인트의 크기에 대한 부분인데요. WebGPU에서 포인트에 대한 렌더링 코드는 다음과 같습니다.

private setupModel() {
  const count = 10000;
  const positions = new Float32Array(count * 3);
  for (let i = 0; i < count; i++) {
    positions[i * 3 + 0] = THREE.MathUtils.randFloatSpread(5);
    positions[i * 3 + 1] = THREE.MathUtils.randFloatSpread(5);
    positions[i * 3 + 2] = THREE.MathUtils.randFloatSpread(5);
  }
  const positionAttribute = new THREE.InstancedBufferAttribute(positions, 3);

  const material = new THREE.PointsNodeMaterial({
    color: 0xffff00,
    positionNode: instancedBufferAttribute(positionAttribute),
    sizeNode: float(.1),
    sizeAttenuation: true,

    alphaTestNode: float(1).sub(shapeCircle()),
  });

  const points = new THREE.Sprite(material);
  points.count = count;
  this.scene.add(points);
}

이 코드는 10000개의 포인트를 렌더링하는 것으로 WebGL에서는 THREE.Points 였던 것이 WebGPU에서는 THREE.Sprite로.. THREE.PointsMaterial 였던 것이 THREE.PointsNodeMaterial로.. 그밖에 포인트들의 위치를 정의하는 부분의 코드 역시도 변경되었습니다. WebGPU 기반에서는 상당 부분을 TSL이라는 개념이 적용되는데 THREE.PointsNodeMaterial을 생성할때 positionNode와 sizeNode 그리고 사각형 모양의 포인트가 아닌 원 모양의 포인트를 만들기 위한 alphaTestNode에서 TSL가 사용되고 있습니다.

WebGPU는 WebGL을 대체하는 기술이고 보다 효율적이고 보다 빠릅니다. 이미 모바일에서도 문제없이 잘작동하고 있구요. 만약 three.js로 새로운 3D 프로젝트를 기획하고 있다면 WebGPU로 방향을 잡아 진행하는 것을 추천합니다.

SVG 파일을 완전한 3D 모델로 만들기

SVG 파일은 2차원 벡터 그래픽 데이터인데, 이를 이용해 three.js에서 완전한 3차원 모델로 렌더링하는 코드의 정리입니다. 먼저 사용할 SVG 파일은 다음과 같습니다.

이 SVG 데이터 파일을 3차원 모델, 즉 Mesh로 만들기 위해서는 Geometry가 필요합니다. 이를 위해 three.js는 SVG 데이터를 받아 처리해주는 SVGLoader를 제공해주고 SVGLoader를 통해 데이터를 해석해 입체감있는 Geometry로 만들어주는 ExtrudeGeometry를 제공합니다. 이 둘을 이용한 코드는 다음과 같습니다.

const svg = await new SVGLoader().loadAsync("./flower.svg");
const paths = svg.paths
const geometries = [];

for (let i = 0; i < paths.length; i++) {
  const path = paths[i];

  const shapes = SVGLoader.createShapes(path);
  const shapeGeometry = new THREE.ExtrudeGeometry(shapes, {
    depth: 1,
    steps: 2,
    curveSegments: 16,
    bevelEnabled: true,
    bevelThickness: 0.5,
    bevelSize: .5,
    bevelOffset: -.15,
    bevelSegments: 8,
  });
  geometries.push(shapeGeometry);
}

const geometry = BufferGeometryUtils.mergeGeometries(geometries)
geometry.center();
geometry.rotateZ(Math.PI);
geometry.scale(.15, .15, .15);

이렇게 만들어진 geometry를 시각해 보면 다음과 같습니다.

빙고! 하지만 곧 꽃의 중심에 대한 구멍(hole)이 이상하게 표현되고 있다는 것을 알 수 있습니다. 직관을 통해 재질의 속성(side: THREE.DoubleSide)을 조정해 보면 다음과 같은 결과를 볼 수 있습니다.

아! 꽃 중심의 구멍의 노말벡터가 뒤짚혔다는 것을 알 수 있습니다. SVG는 구멍을 정의하기 위해서 정점의 구성 순서를 시계 방향으로 해야 합니다. 구멍이 아닌 외곽은 반시계 방향으로 구성해야 하구요. 그런데 사용하고 있는 SVG 데이터는 이러한 규칙을 무시하고 있습니다. 정해준 규칙을 따르는 SVG 데이터를 제공받아 사용하면 좋겠지만, 저는 그렇게 하지 않고 코드를 통해 해당 규칙을 따르도록 만들겠습니다.해당 코드가 적용된 것은 같습니다.

const svg = await new SVGLoader().loadAsync("./flower.svg");
const paths = svg.paths
const geometries = [];

for (let i = 0; i < paths.length; i++) {
  const path = paths[i];

  const correctedShapes = [];
  const shapes = SVGLoader.createShapes(path);
  for (let j = 0; j < shapes.length; j++) {
    const shape = shapes[j];

    // 외곽 포인트
    const outerPts = shape.getPoints(64);
    if (THREE.ShapeUtils.isClockWise(outerPts)) outerPts.reverse();
    const newShape = new THREE.Shape(outerPts);

    // 홀 처리
    shape.holes.forEach(hole => {
      const holePts = hole.getPoints(64);
      // 홀은 시계 방향이어야 하므로, 반대인 경우 뒤집음
      if (!THREE.ShapeUtils.isClockWise(holePts)) holePts.reverse();
      newShape.holes.push(new THREE.Path(holePts));
    });

    correctedShapes.push(newShape);
  }

  const shapeGeometry = new THREE.ExtrudeGeometry(correctedShapes, {
    ...
  });
  geometries.push(shapeGeometry);
}

const geometry = BufferGeometryUtils.mergeGeometries(geometries)
...

이제 결과를 보면 다음처럼 SVG에 대한 완전한 지오메트리 구성이 된 것을 확인할 수 있습니다.

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

텍스처는 이미지 그 이상의 가치를 가진 데이터이다. 텍스쳐를 쉐이더로 넘길때 흔히 하나씩 넘기는 경우가 흔한데, 가능하다면 한꺼번에 넘기는게 속도면에서 훨씬 이득이다. 즉, 텍스쳐 배열 타입(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))
    };
  }
}

뭐.. 별거 없죠?

베지어(bezier)와 기울기

베지어 함수의 코드는 다음과 같다.

vec3 bezier(vec3 P0, vec3 P1, vec3 P2, vec3 P3, float t) {
  float u = 1.0 - t;
  float tt = t * t;
  float uu = u * u;
  float uuu = uu * u;
  float ttt = tt * t;

  vec3 p = uuu * P0; // (1-t)^3 * P0
  p += 3.0 * uu * t * P1; // 3*(1-t)^2*t*P1
  p += 3.0 * u * tt * P2; // 3*(1-t)*t^2*P2
  p += ttt * P3; // t^3*P3

  return p;
}

베지어 상의 접선에 대한 함수 코드는 다음과 같다.

vec3 bezierGrad(vec3 P0, vec3 P1, vec3 P2, vec3 P3, float t) {
  return 3.0 * (1.0 - t) * (1.0 - t) * (P1 - P0) +
    6.0 * (1.0 - t) * t * (P2 - P1) +
    3.0 * t * t * (P3 - P2);
}

접선에 대한 벡터를 90도 회전하면 베지어의 법선 벡터를 구할 수 있다.

Instanced Mesh in Shader

인스턴스드 매시는 다음처럼 생성할 수 있습니다. 지오메트리의 좌표 구성을 위해 BoxGeometry의 것을 가져다 쓰는 경우입니다. 지오메트리의 index와 position만을 필요로 하니 아래처럼 했고, 그냥 new THREE.InstancedBufferGeometry.copy(baseGeometry)로 하면 지오메트리를 그대로 복사합니다.

const baseGeometry = new THREE.BoxGeometry(0.5, 0.5, 0.5);
const geometry = new THREE.InstancedBufferGeometry();

geometry.index = baseGeometry.index;
geometry.attributes.position = baseGeometry.attributes.position;

// 위의 코드는 참조인지라 아래의 코드로 대체하는게 맞죠.
// geometry.setIndex(baseGeometry.index);
// geometry.setAttribute("position", baseGeometry.attributes.position);

인스턴스로 만들 개수를 지정해야 합니다.

const count = 100;
geometry.instanceCount = count;

인스턴스화된 것들에 대한 개별 요소들은 위치, 회전, 크기, 색상에 대해 개별적으로 지정이 가능한데 위치와 색상에 대한 지정 코드입니다.

const offsets = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
  offsets[i * 3 + 0] = (Math.random() - 0.5) * 10; // x
  offsets[i * 3 + 1] = (Math.random() - 0.5) * 10; // y
  offsets[i * 3 + 2] = (Math.random() - 0.5) * 10; // z
}
geometry.setAttribute("instanceOffset", new THREE.InstancedBufferAttribute(offsets, 3));

const colors = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
  colors[i * 3 + 0] = Math.random(); // R
  colors[i * 3 + 1] = Math.random(); // G
  colors[i * 3 + 2] = Math.random(); // B
}
geometry.setAttribute("instanceColor", new THREE.InstancedBufferAttribute(colors, 3));

쉐이더를 통해 직접 인스턴스 매시를 렌더링하기 위해 재질을 설정하는 코드입니다.

const material = new THREE.ShaderMaterial({
  vertexShader: /*glsl*/ `
    attribute vec3 instanceOffset;
    attribute vec3 instanceColor;
    varying vec3 vColor;
    
    void main() {
      vec3 transformed = position + instanceOffset;
      vColor = instanceColor;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0);
    }
  `,
  fragmentShader: /*glsl*/ `
    varying vec3 vColor;
    void main() {
      gl_FragColor = vec4(vColor, 1.0);
    }
  `
});

이제 장면에 매시를 넣으면 화면에 딱... 표시되어야 합니다.

const mesh = new THREE.Mesh(geometry, material);
this._scene.add(mesh);