Soft Min/Max

SDF에서 두 개의 Distance를 섞을 때….. 합은 최소로, 교차는 최대로 얻을 수 있다. 하지만 두 개의 거리가 만나는 지점에 너무 칼처럼 정확하면 보기가 딱딱해 보이는데.. 예를 들어 다음과 같다.

material.colorNode = Fn(([]) => {
  const color = backgroundColor(0.015, BLACK, GRAY);

  const st = uv().sub(.5);

  const A = sdfHexagon(st.sub(vec2(-.1, -.13)), .25);
  const B = sdfBox(st.sub(vec2(.1, .2)), vec2(.2, .2));

  const D = min(A, B, 100);

  color.assign(mix(RED, color, D.smoothstep(0, 0.0025)));
  color.assign(mix(BLACK, color, D.abs().smoothstep(0.005, 0.0052)));

  return vec4(color, 1);
})();

두 개의 거리가 만나는 지점을 좀더 부드럽게 표현하기 위해 Soft Min/Max를 사용하면 되는데, 먼저 Soft Min과 Max에 대한 함수는 다음과 같다.

const softMax = /*@__PURE__*/ Fn(([a, b, k]) => {
  return log(exp(k.mul(a)).add(exp(k.mul(b)))).div(k);
}, { a: 'float', b: 'float', k: 'float', return: 'float' });

const softMin = /*@__PURE__*/ Fn(([a, b, k]) => {
  return softMax(a.negate(), b.negate(), k).negate();
}, { a: 'float', b: 'float', k: 'float', return: 'float' });

앞서봤던 예제 코드에 위의 Soft Min를 적용한 코드는 다음과 같다.

material.colorNode = Fn(([]) => {
  const color = backgroundColor(0.015, BLACK, GRAY);

  const st = uv().sub(.5);

  const A = sdfHexagon(st.sub(vec2(-.1, -.13)), .25);
  const B = sdfBox(st.sub(vec2(.1, .2)), vec2(.2, .2));

  const D = softMin(A, B, 80);

  color.assign(mix(RED, color, D.smoothstep(0, 0.0025)));
  color.assign(mix(BLACK, color, D.abs().smoothstep(0.005, 0.0052)));

  return vec4(color, 1);
})();

끝으로 이와 관련된 또 다른 Soft Min, Max에 대한 글은 아래를 참조하자.

2개의 모양 함수를 smooth하게 섞는 방법(smooth min/max)

THREE.JS 퀵 레퍼런스 코드

Three.js를 이용한 개발 시 개인적으로 빠르게 참조하기 위해 작성한 글입니다.

three.js 기본 프로젝트 생성 (WebGL)

git clone https://github.com/GISDEVCODE/threejs-with-javascript-starter.git .

three.js 기본 프로젝트 생성 (WebGPU)

git clone https://github.com/GISDEVCODE/threejs-webgpu-with-javascript-starter.git .

R3F 기본 프로젝트 생성 (Javascript)

git clone https://github.com/GISDEVCODE/r3f-with-javascript-starter.git .

Shader 기본 프로젝트 생성 (Javascript)

git clone https://github.com/GISDEVCODE/shader-with-threejs-javascript-starter .

그림자 적용에 대한 코드

renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.VSMShadowMap;

const shadowLight = new THREE.DirectionalLight(0xffe79d, 0.7);
shadowLight.position.set(150, 220, 100);
shadowLight.target.position.set(0,0,0);
shadowLight.castShadow = true;
shadowLight.shadow.mapSize.width = 1024*10;
shadowLight.shadow.mapSize.height = 1024*10;
shadowLight.shadow.camera.top = shadowLight.shadow.camera.right = 1000;
shadowLight.shadow.camera.bottom = shadowLight.shadow.camera.left = -1000;
shadowLight.shadow.camera.far = 800;
shadowLight.shadow.radius = 5;
shadowLight.shadow.blurSamples = 5;
shadowLight.shadow.bias = -0.0002;

const cameraHelper = new THREE.CameraHelper(shadowLight.shadow.camera);
this._scene.add(cameraHelper);

island.receiveShadow = true;
island.castShadow = true;

지오메트리의 좌표 수정

const sphereGeom = new THREE.SphereGeometry(6 + Math.floor(Math.random() * 12), 8, 8);
const sphereGeomPosition = sphereGeom.attributes.position;
for (var i = 0; i < sphereGeomPosition.count; i++) {
    sphereGeomPosition.setY(i, sphereGeomPosition.getY(i) + Math.random() * 4 - 2);
    sphereGeomPosition.setX(i, sphereGeomPosition.getX(i) + Math.random() * 3 - 1.5);
    sphereGeomPosition.setZ(i, sphereGeomPosition.getZ(i) + Math.random() * 3 - 1.5);
}

sphereGeom.computeVertexNormals();
sphereGeom.attributes.position.needsUpdate = true;

지오메트리에 사용자 정의 데이터 주입

// 주입
const waves = [];
const waterGeoPositions = waterGeo.attributes.position;
for (let i = 0; i < waterGeoPositions.count; i++) {
    waves[i] = Math.random() * 100;
}
waterGeo.setAttribute("wave", new THREE.Float32BufferAttribute(waves, 1));

// 읽기
const waves = sea.geometry.attributes.wave;

for(let i=0; i<positions.count; i++) {
    const v = waves.getX(i);
}

안개 설정 코드

scene.fog = new THREE.Fog("rgba(54,219,214,1)", 1000, 1400);

OrbitControls 관련 코드

const controls = new OrbitControls(this._camera, this._divContainer);
controls.minPolarAngle = -Math.PI / 2;
controls.maxPolarAngle = Math.PI / 2 + 0.1;
controls.enableZoom = true;
controls.enablePan = false;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.2;

this._controls = controls;

this._controls.update();

Object3D의 MBR 얻기

const board = this._scene.getObjectByName("Board");
const box = new THREE.Box3().setFromObject(board);
console.log(box);

Mesh의 월드좌표에 대한 position 얻기

mesh.updateMatrixWorld();

const worldPos = new THREE.Vector3();
worldPos.setFromMatrixPosition(mesh.matrixWorld);

Faked Shadow

그림자를 위한 매시에 대한 재질 속성 지정이 핵심. 참고로 shadow에 대한 이미지는 투명 이미지가 아님. 즉, 배경색이 하얀색인 이미지임.

const shadow = new THREE.TextureLoader().load( 'models/gltf/ferrari_ao.png' );

const mesh = new THREE.Mesh(
    new THREE.PlaneGeometry( 0.655 * 4, 1.3 * 4 ),
    new THREE.MeshBasicMaterial( {
        map: shadow, 
        blending: THREE.MultiplyBlending, 
        toneMapped: false, 
        transparent: true
    } )
);
mesh.rotation.x = - Math.PI / 2;
mesh.renderOrder = 2;
carModel.add( mesh );

텍스쳐 이미지 품질 올리기

샘플링 횟수를 올리는 것으로 속도는 느려질 수 있으나 품질은 향상됨

texture.anisotropy = renderer.capabilities.getMaxAnisotropy();

async 리소스 로딩

async function init() {
    const rgbeLoader = new RGBELoader().setPath('textures/equirectangular/');
    const gltfLoader = new GLTFLoader().setPath('models/gltf/DamagedHelmet/glTF/');

    const [texture, gltf] = await Promise.all([
        rgbeLoader.loadAsync( 'venice_sunset_1k.hdr' ),
        gltfLoader.loadAsync( 'DamagedHelmet.gltf' ),
    ]);
}

init().catch(function(err) {
    console.error(err);
});

텍스쳐를 Canvas로 후다닥 만들기

const canvas = document.createElement( 'canvas' );
canvas.width = 1;
canvas.height = 32;

const context = canvas.getContext( '2d' );
const gradient = context.createLinearGradient( 0, 0, 0, 32 );
gradient.addColorStop( 0.0, '#ff0000' );
gradient.addColorStop( 0.5, '#00ff00' );
gradient.addColorStop( 1.0, '#0000ff' );
context.fillStyle = gradient;
context.fillRect( 0, 0, 1, 32 );

const sky = new THREE.Mesh(
	new THREE.SphereGeometry( 10 ),
	new THREE.MeshBasicMaterial( { map: new THREE.CanvasTexture( canvas ), side: THREE.BackSide } )
);
scene.add( sky );

GLTF 파일 로딩

import { GLTFLoader } from "../examples/jsm/loaders/GLTFLoader.js"

const loader = new GLTFLoader();
loader.load("./data/ring.glb", gltf => {
    const object = gltf.scene;
    this._scene.add(object);
});    

InstancedMesh

const mesh = new THREE.InstancedMesh(geometry, material, 10000)

const matrix = new THREE.Matrix4()
const dummy = new THREE.Object3D()
 
for(let i = 0; i < 10000; i++) {
  mesh.getMatrixAt(i, matrix)
  matrix.decompose(dummy.position, dummy.rotation, dummy.scale)
  
  dummy.rotation.x = Math.random()
  dummy.rotation.y = Math.random()
  dummy.rotation.z = Math.random()

  dummy.updateMatrix()

  mesh.setMatrixAt(i, dummy.matrix)
  mesh.setColorAt(i, new THREE.Color(Math.random() * 0xffffff)
}

mesh.instanceMatrix.needsUpdate()

Image 기반 광원(IBL)

import { RGBELoader } from 'three/examples/jsm/Addons.js'

...

new RGBELoader().setPath("./").load("pine_attic_2k.hdr", (data) => {
  data.mapping = THREE.EquirectangularReflectionMapping;
  // this.scene.background = data;
  // this.scene.backgroundBlurriness = 0.6;
  this.scene.environment = data;
})

GLTF / GLB 파일 로딩

import { GLTFLoader, OrbitControls } from "three/addons/Addons.js"

...

const loader = new GLTFLoader();
loader.load(
  "fileName.glb",
  (gltf) => {
    this._scene.add( gltf.scene );    
  },
  (xhr) => { console.log( ( xhr.loaded / xhr.total * 100 ) + "% loaded" ); },
  (error) => { console.log( "An error happened" ); }
);

Object3D를 빌보드로 만들기

this._mesh.quaternion.copy(this._camera.quaternion );
// or
this._mesh.rotation.setFromRotationMatrix( this._camera.matrix );

FPS 측정

let frameCount = 0;
let lastTime = performance.now();
let fps = 60;

function updateFPS() { // 렌더링 함수에서 호출해야 함
  frameCount++;
  const currentTime = performance.now();
  const deltaTime = currentTime - lastTime;

  if (deltaTime >= 1000) {
    fps = Math.round((frameCount * 1000) / deltaTime);
    frameCount = 0;
    lastTime = currentTime;
  }
}

FPS 제한하기

requestAnimationFrame로 렌더링을 수행하면 최대한 많은 프레임을 생성하기 됨. 아래는 원하는 프레임수로 제한하기 위해 다음 코드로 30 프레임 제한입니다.

  _elapsedTime = 0;
  _fps = 1 / 60
  render() {
    const delta = this._clock.getDelta();
    this.update(delta);
    this._elapsedTime += delta;
    
    if (this._elapsedTime >= (this._fps)) {
      this._stats.begin();
      this._renderer.render(this._scene, this._camera);
      this._stats.end();
      this._elapsedTime %= this._fps;
    }

    requestAnimationFrame(this.render.bind(this));
  }

화면 좌표를 월드 좌표로 변환하는 함수

// 스크린 좌표 -> 월드 좌표 변환 함수
screenToWorld(screenX, screenY, ndcZ = 0.5) { // ndcZ=0.5로 설정 (카메라 방향)
  // 1. 화면 픽셀 좌표를 정규화된 디바이스 좌표(NDC)로 변환
  const ndcX = (screenX / this._divContainer.clientWidth) * 2 - 1;
  const ndcY = -(screenY / this._divContainer.clientHeight) * 2 + 1;

  // 2. NDC를 3D 공간의 Ray로 변환
  const vector = new THREE.Vector3(ndcX, ndcY, ndcZ);
  vector.unproject(this._camera);

  // 3. Ray를 따라 월드 좌표를 계산
  return vector;
}

screenToWorldAtZ(screenX, screenY, targetZ = 0) {
  // 1. 화면 픽셀 좌표 -> NDC 변환
  const ndcX = (screenX / this._divContainer.clientWidth) * 2 - 1;
  const ndcY = -(screenY / this._divContainer.clientHeight) * 2 + 1;

  // 2. NDC -> Ray 생성
  const raycaster = new THREE.Raycaster();
  const vector = new THREE.Vector2(ndcX, ndcY);
  raycaster.setFromCamera(vector, this._camera);

  // 3. Ray와 z=targetZ 평면의 교차점 계산
  const direction = raycaster.ray.direction;
  const origin = raycaster.ray.origin;
  const t = (targetZ - origin.z) / direction.z;

  const worldPoint = origin.clone().add(direction.multiplyScalar(t));
  return worldPoint;
}

모델을 화면 중심에 표시하기

_zoomFix(model, distanceFactor = 1.5) {
  const box = new THREE.Box3().setFromObject(model);
  const center = box.getCenter(new THREE.Vector3());

  model.position.sub(center);

  const size = box.getSize(new THREE.Vector3());
  const maxDimension = Math.max(size.x, size.y, size.z);
  const cameraDistance = maxDimension * distanceFactor;
  this._camera.position.set(0, 0, cameraDistance);
  this._camera.lookAt(this._scene.position);
}

모델의 특정 페이스에 대한 법선 벡터 구하기

_getNormal(mesh, faceIndex) {
  const normalAttribute = mesh.geometry.getAttribute("normal");
  const index = mesh.geometry.index;
  const faceStartIndex = faceIndex * 3;

  const vertexIndexA = index.getX(faceStartIndex);
  const vertexIndexB = index.getX(faceStartIndex + 1);
  const vertexIndexC = index.getX(faceStartIndex + 2);

  const normalA = new THREE.Vector3();
  const normalB = new THREE.Vector3();
  const normalC = new THREE.Vector3();
  const faceNormal = new THREE.Vector3();

  normalA.fromBufferAttribute(normalAttribute, vertexIndexA);
  normalB.fromBufferAttribute(normalAttribute, vertexIndexB);
  normalC.fromBufferAttribute(normalAttribute, vertexIndexC);

  faceNormal.addVectors(normalA, normalB).add(normalC).divideScalar(3).normalize();

  return faceNormal;
}

어떤 위치에서 매시에 가장 가까운 매시 표면의 위치를 얻는 코드

아래 함수의 point 파라메터는 어떤 위치이고 mesh 파라메터가 대상 메시입니다. point에서 가장 가까운 매시 표면의 좌표가 변환됩니다.

findClosestPointOnMesh(/* Vector3 */ point, /* Mesh */ mesh) {
  const geometry = mesh.geometry;
  const vertices = geometry.attributes.position;
  const indices = geometry.index ? geometry.index.array : null;

  let closestPoint = new THREE.Vector3();
  let minDistance = Infinity;

  const localPoint = mesh.worldToLocal(point);

  if (indices) {
    for (let i = 0; i < indices.length; i += 3) {
      const a = new THREE.Vector3().fromBufferAttribute(vertices, indices[i]);
      const b = new THREE.Vector3().fromBufferAttribute(vertices, indices[i + 1]);
      const c = new THREE.Vector3().fromBufferAttribute(vertices, indices[i + 2]);

      const tempPoint = new THREE.Vector3();
      const triangle = new THREE.Triangle(a, b, c);
      triangle.closestPointToPoint(localPoint, tempPoint);

      const distance = localPoint.distanceTo(tempPoint);
      if (distance < minDistance) {
        minDistance = distance;
        closestPoint.copy(tempPoint);
      }
    }
  } else {
    for (let i = 0; i < vertices.count; i += 3) {
      const a = new THREE.Vector3().fromBufferAttribute(vertices, i);
      const b = new THREE.Vector3().fromBufferAttribute(vertices, i + 1);
      const c = new THREE.Vector3().fromBufferAttribute(vertices, i + 2);

      const tempPoint = new THREE.Vector3();
      const triangle = new THREE.Triangle(a, b, c);
      triangle.closestPointToPoint(localPoint, tempPoint);

      const distance = localPoint.distanceTo(tempPoint);
      if (distance < minDistance) {
        minDistance = distance;
        closestPoint.copy(tempPoint);
      }
    }
  }

  return mesh.localToWorld(closestPoint);
}

아래는 위의 함수가 적용된 실행 화면인데, 노란색 포인트 위치가 임의의 위치(point 파라메터)이고 빨간색이 함수의 결과 좌표입니다.

DataTexture를 이용한 Raw 데이터로 텍스쳐 생성

    const width = 256;
    const height = 256;
    const size = width * height;
    const data = new Uint8Array(4 * size); // RGBA 데이터

    for (let i = 0; i < size; i++) {
      const stride = i * 4;

      data[stride] = Math.floor(Math.random() * 256);     // R
      data[stride + 1] = Math.floor(Math.random() * 256); // G
      data[stride + 2] = Math.floor(Math.random() * 256); // B
      data[stride + 3] = 255;                             // A
    }

    const texture = new THREE.DataTexture(data, width, height);
    texture.needsUpdate = true;

    const material = new THREE.MeshBasicMaterial({ map: texture });
    const mesh = new THREE.Mesh(new THREE.PlaneGeometry(4, 4), material);
    this._scene.add(mesh);

여러 개의 지오메트리들을 한 개의 지오메트리로 병합하기

const mat3 = new THREE.MeshBasicMaterial();
const pumpkin = await this.loadGLTF('./resources/models/pumpkin.glb');
const geos = [];

// 아래의 2줄에 대한 변환 코드는 updateMatrixWorld 매서드가 실행되면 변환 값이 matrixWorld에 반영됨
pumpkin.children[0].position.set(0, 0, 0);
pumpkin.children[0].scale.setScalar(0.01);

pumpkin.children[0].traverse(child => {
  child.updateMatrixWorld(); 
  if(child.isMesh) {
    if(child.geometry) {
      const attribute = child.geometry.getAttribute('position').clone();
      const geometry = new THREE.BufferGeometry();
      geometry.setIndex(child.geometry.index);
      geometry.setAttribute('position', attribute);
      geometry.applyMatrix4(child.matrixWorld);
      geos.push(geometry);
    }
  }
});
const combinedGeometry = BufferGeometryUtils.mergeGeometries(geos);
const combinedMesh = new THREE.Mesh(combinedGeometry, mat3);

마우스 커서 위치에 대한 3차원 공간 좌표(평면 기준) 얻기

const mouse3D = new THREE.Vector3(0, 0, 0);
const raycaster = new THREE.Raycaster();
const intersectionPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);

window.addEventListener('mousemove', (event) => {
  const mouse = new THREE.Vector2(
    (event.clientX / window.innerWidth) * 2 - 1,
    -(event.clientY / window.innerHeight) * 2 + 1
  );
  raycaster.setFromCamera(mouse, camera);

  // Plane 수학 모델을 통한 마우스 커서의 Plane 상의 3차원 위치 얻기(mouse3D에 저장)
  raycaster.ray.intersectPlane(intersectionPlane, mouse3D);
});

TSL 노드함수인 instancedArray로 생성된 GPU 메모리를 CPU 메모리의 배열형태로 가져오기

this.velocityBuffer = instancedArray(this.COUNT, 'vec3');

...

const resultBuffer = await renderer.getArrayBufferAsync(this.velocityBuffer.value);
const resultArray = new Float32Array(resultBuffer);
console.log(resultArray)

billboarding TSL

material.vertexNode = billboarding({ horizontal: true, vertical: true });

편미분에 의한 노말 벡터 구하기

const normalVector = Fn(([pos]) => {
  const dU = dFdx(pos).toConst(), dV = dFdy(pos).toConst();
  return transformNormalToView(cross(dU, dV).normalize());
  // return cross(dU, dV).normalize();
});

normalVector의 인자값으로 positionLocal 노드가 올 수 있다.

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)

이제 정말 끝.

R3F에서 lint 에러 제거하기

아래처럼 빨간펜 선생님의 지적이 눈에 거슬린다면 …

먼저 위의 코드 중 하나는 다음처럼…

extend(THREE as any);

그리고 src/@types 폴더를 만들고 원하는 이름의 확장자가 *.d.ts 인 파일(나의 경우 three.webgpu.d.ts)을 만들어 다음처럼 내용을 채웁니다.

import * as THREE from 'three/webgpu';
import { ReactThreeFiber } from '@react-three/fiber';

declare module '@react-three/fiber' {
  interface ThreeElements {
    meshBasicNodeMaterial: ReactThreeFiber.Object3DNode;
  }
}

그럼 더 이상 빨간펜 선생님의 잔소리는 사요나라… 입니다. 물론 필요할 경우 지적당한 타입을 *.d.ts에 계속 추가해야 합니다.

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로 방향을 잡아 진행하는 것을 추천합니다.