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

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

하나의 지오메트리에 대한 매시에 여러 개의 재질 반영하기

매시는 하나의 지오메트리와 2개 이상의 재질로 정의됩니다. three.js 중급 개발자라면 메시가 1개가 아닌 2개 이상의 재질로 정의된다는 점에 의구심을 가질 수 있을텐데요. 하지만 맞습니다. 매시는 2개 이상의 재질을 갖습니다. 하지만 지오메트리는 1개입니다. 이상하죠? 지오메트리가 1개면 그에 대한 재질도 1개여야 맞는거 같은데 말이죠. 그래서 지오메트리의 구성 좌표를 그룹화할 수 있습니다. 재질이 2개라면 2개의 그룹으로 지오메트리의 구성 좌표를 구성하는거죠.

예를들어 다음과 같은 결과를 봅시다.

SphereGeometry로 만든 매시입니다. 그런데 위 아래로 다른 재질이 부여되어 있습니다. 마치 2개의 매시를 뭍여 놓은 것처럼요.

아래는 코드입니다.

const geom = new THREE.SphereGeometry(2);

const indexCount = geom.index.count;
const midIndex = Math.floor(indexCount / 2);

geom.clearGroups();
geom.addGroup(0, midIndex, 0);
geom.addGroup(midIndex, indexCount - midIndex, 1);

const mesh = new THREE.Mesh(geom, [
    new THREE.MeshPhysicalMaterial({ metalness: 1, roughness: 0 }),
    new THREE.MeshNormalMaterial()
]);

scene.add(mesh);

지오메트리에 대한 2개의 그룹핑, 2개의 재질을 적용해 만든 매시에 대한 코드가 보이죠? 지오메트리에 대한 정점의 그룹핑 단위는 인덱스(삼각형을 구성하는 인덱스)입니다. 하지만 경우에 따라서 인덱스가 아닌 정점 하나 하나를 지정해서 만든 넌-인덱스 방식도 존재합니다. 아래는 결과는 동일하지만 넌-인덱스 방식의 지오메트리에 대한 그룹핑 코드입니다.

let geom = new THREE.SphereGeometry(2, 128, 64);
geom = geom.toNonIndexed(); // Non-indexed 지오메트리

const vertexCount = geom.getAttribute("position").count;
const midVertex = Math.floor(vertexCount / 2);

geom.clearGroups();
geom.addGroup(0, midVertex, 0);
geom.addGroup(midVertex, vertexCount - midVertex, 1);

const mesh = new THREE.Mesh(geom, [
    new THREE.MeshPhysicalMaterial({ metalness: 1, roughness: 0 }),
    new THREE.MeshNormalMaterial()
]);

scene.add(mesh);

이번에는 지오메트리에 대한 2개의 그룹을 만들기 위해 버텍스에 대한 인덱스를 사용한 것을 알 수 있습니다.

여튼 지금까지 봤던 three.js에서의 지오메트리에 대한 그룹핑과 매시에 여러개의 재질을 지정할 수 있다는 것을 아셔야, 3D 모델링 툴에서 만들어진 모델들의 구성을 이해할 수 있게 됩니다.

프레넬(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.);