three.js, 사용자 정의 TSL 노드 (add 노드를 예시로…)

three.js의 TSL에서 재사용이 가능한 코드를 TSL 노드화하여 제공하면 DX를 향상시킬 수 있습니다. 이미 제공하고 있는 add 노드와 동일하게 작동하는 myAdd 노드를 예시로 사용자 정의 노드를 만드는 코드입니다.

두가지 방식이 가능하며 첫번째는 프리셋 노드를 활용하는 방식입니다.

class MyAddNode extends THREE.TempNode {
  static get type() {
    return 'MyAddNode';
  }

  constructor(aNode, bNode) {
    super('float'); // 출력 타입을 float로 고정

    this.aNode = aNode;
    this.bNode = bNode;
  }

  // 실제 연산 로직 - 기존 TSL 연산자를 조합해서 구성
  setup() {
    const { aNode, bNode } = this;

    return float(aNode).add(float(bNode));
  }
}

export default MyAddNode;

이렇게 정의된 노들 함수형 문법과 메서드 체이닝 문법으로 사용될 수 있도록 하는 코드는 다음과 같습니다.

export default MyAddNode;

// 함수형 문법으로 노출: myAdd( a, b )
export const myAdd = /*@__PURE__*/ nodeProxy(MyAddNode).setParameterLength(2).setName('myAdd');

// 메서드 체이닝 지원: a.myAdd( b )
addMethodChaining('myAdd', myAdd);

프리셋 노드를 사용하는 방식은 커스텀 노드 개발이 간단하다는 장점은 있으나 원하는 기능을 가진 프리셋 노드가 없을 경우 WGSL 코드나 GLSL 코드를 직접 이용하는 방식은 아래와 같습니다. 앞서 봤던 것과 동일한 기능을 가진 노드의 클래스입니다.

class MyAddNode extends THREE.TempNode {
  static get type() {
    return 'MyAddNode';
  }

  constructor(aNode, bNode) {
    super('float');
    this.aNode = aNode;
    this.bNode = bNode;
  }

  generate(builder, output) {
    const type = this.getNodeType(builder); // 'float'

    const a = this.aNode.build(builder, type);
    const b = this.bNode.build(builder, type);

    return builder.format(`( ${a} + ${b} )`, type, output);
  }
}

three.js, 후처리 적용시 …

후처리가 적용되면 GPU 단계에서 적용되는 안티알리아싱(MSAA) 기능이 제대로 작동하지 않습니다. 즉, 다음 옵션은 불필요한 GPU 연산을 유발합니다.

const renderer = new THREE.WebGPURenderer({
    canvas: canvas,
    antialias: true // 이 옵션!
});

GPU가 해주던 안티알리아싱의 대안으로 후처리가 대신해줘야 하는데요. 다음처럼 FXAA 알고리즘을 사용하는 노드를 통해 처리가 가능합니다.

import { fxaa } from 'three/examples/jsm/tsl/display/FXAANode.js'


...

const fxaaPass = fxaa(renderPipeline.outputNode);
renderPipeline.outputNode = fxaaPass;

그런데, 꼼꼼한 개발자라면 색상에 대한 선형값을 우리의 눈에 익숙한 비선형값으로 변환해주는 처리가 안티알리아싱이 적용되기 직전에 수행되도록 해주는 것이 자연스럽습니다. 기본값은 안티알리아싱이 처리된 이후 색상 변환이 작동되거든요.

이를 위해 먼저 색상값을 화면에 표시할때 색상 변환이 작동하지 않도록 해줘야 합니다. 다음처럼요.

const renderPipeline = new THREE.RenderPipeline(renderer)
renderPipeline.outputColorTransform = false;

그리고 FXAA 방식의 안티알리아싱이 적용되기 직전의 렌더링 파이프라인에 다음 코드를 추가합니다.

import { ..., renderOutput } from 'three/tsl'

...

// Color transform pass
renderPipeline.outputNode = renderOutput(renderPipeline.outputNode);

const fxaaPass = fxaa(renderPipeline.outputNode)
...

renderOutput는 렌더러의 toneMapping 속성과 toneMappingExposure 속성을 처리해주는 노드입니다.

매시 외곽선 표현 (임시)

const scenePass = pass(scene, camera)
scenePass.setMRT(mrt({
    output: output,
    normalFlat: normalFlat,
    normal: normalWorld
}));

const sobelPass1 = sobel(scenePass.getTextureNode('normal')).pow(4);
const sobelPass2 = sobel(scenePass.getTextureNode('normalFlat'));
renderPipeline.outputNode = scenePass.getTextureNode('output').add(
    sobelPass1.max(sobelPass2)
);

(todo) sobelPass1에 의미있는 외곽선만을 남기기 위한 pow(4) 연산 대신 remapClamp(0.2, 1, 0, 1)로 대체해 테스트해 볼 것. 참고로 pow 연산은 1보다 작은 값을 더 0에 가깝게 만들고 1보다 큰 값은 더 크게 만들어 주는 것으로 이해하면 쉽다.

DPI를 구하는 2가지 방식

한가지는 이미 알고 있던 사용해 왔던 방식입니다.

const DPR = window.devicePixelRatio;
const DPI = DPR * 96;

두번째는 좀더 정확한 DPI를 얻는 방식이라고 합니다.

function getExactDPI() {
  for (let dpi = 1; dpi < 600; dpi++) {
    if (window.matchMedia(`(max-resolution: ${dpi}dpi)`).matches) {
      return dpi;
    }
  }
  return 96; // 기본값
}

어찌되었든 웹에서 DPI는 보안정책 상 정확한 값은 얻기 어렵다고 합니다.

three.js의 TSL을 이용한 attribute 관련 코드

attribute를 위한 데이터인 Buffer Attribte를 생성하는 방식은 기존과 동일합니다.

const count = geometry.attributes.position.count
const randomArray = new Float32Array(count)

for (let i = 0; i < count; i++)
    randomArray[i] = Math.random()

const randomBuffer = new THREE.BufferAttribute(randomArray, 1)

// KEY POINT ->
geometry.setAttribute('random', randomBuffer)
const random = attribute('random')
// <-

material.colorNode = random

위의 코드는 다음 코드처럼 좀더 짧고 효과적으로 변경될 수 있습니다.

const count = geometry.attributes.position.count
const randomArray = new Float32Array(count)

for (let i = 0; i < count; i++)
    randomArray[i] = Math.random()

const randomBuffer = new THREE.BufferAttribute(randomArray, 1)

// KEY POINT ->
const random = bufferAttribute(randomBuffer, 'float')
// <-

material.colorNode = random