three.js, 대규모 장면에서의 그림자 적용을 위한 SunLight

SunLight는 태양광인데, 흔히 알고 있고 있는 DirectionalLight와 비슷합니다. 다만 그림자로써의 DirectionLight와 다르게 처리되는데 훨씬 큰 범위의 장면에 대한 그림자를 Cascade 방식으로 처리하여 표현합니다. SunLight을 적용하기 위한 코드를 순서대로 정리하면 다음과 같습니다.

먼저 필요한 클래스를 import 합니다.

import { SunLight } from 'three/addons/lights/SunLight.js';
import { SunLightNode } from 'three/addons/lights/SunLightNode.js';

SunLight는 그림자 표현을 위해 SunLightNode라는 TSL 노드를 사용합니다. 이 TSL 노드는 기본 노드가 아닌 확장 노드이므로 등록이 필요합니다. 다음처럼 등록할 수 있습니다.

this.renderer.library.addLight(SunLightNode, SunLight);

실제 SunLight를 생성하는 코드는 다음과 같습니다.

private sunLight!: SunLight;

private setupSunLight() {
  this.renderer.library.addLight(SunLightNode, SunLight);

  const sunLight = new SunLight();
  sunLight.position.setFromSphericalCoords(1, THREE.MathUtils.degToRad(90 - params.elevation), THREE.MathUtils.degToRad(params.azimuth));

  sunLight.castShadow = true;
  sunLight.shadow.camera.far = params.far; // maximum shadow distance
  sunLight.shadow.mapSize.setScalar(params.resolution);
  sunLight.shadow.normalBias = 0.05;
  this.scene.add(sunLight);

  this.sunLight = sunLight;
}

위의 코드를 보면 설정을 위해 param 객체를 사용하고 있는데, 해당 객체는 다음 예시와 같습니다.

const params = {
  showCascades: false,
  far: 1000,
  resolution: 2048,
  azimuth: 135,
  elevation: 10
};

이외에 기본적으로 그림자를 위한 렌더러와 매시에 대한 코드도 추가해야 합니다. 즉..

renderer.shadowMap.enabled = true;

...

mesh.castShadow = true;
mesh.receivedShadow = true;

WebGPU 기능 감지

거의 모든 웹브라우저가 WebGPU를 지원하지만 확인은 다음과 같이 할 수 있습니다.

const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) {
  return;
}

WebGPU 지원과는 별개로 WebGPU에서 제공하는 기능은 그 단위마다 지원 여부를 확인해야할 필요는 있습니다. 이에 대한 코드는 다음과 같습니다.

const hasFloat32Filtering = adapter.features.has('float32-filterable');
const hasTimestamps = adapter.features.has('timestamp-query');

three.js, WebGPU에서 지오메트리를 구성하는 좌표에 파티클 배치하기

위의 결과를 위해서 3가지 방식이 있습니다. 첫번째는 …

private setupModel() {
  const knotGeometry = new THREE.TorusKnotGeometry(1, 0.3, 256, 64);
  const positionAttribute = knotGeometry.attributes.position;
  const count = positionAttribute.count;

  const instancePositions = new Float32Array(count * 3);
  for (let i = 0; i < count; i++) {
    instancePositions[i * 3] = positionAttribute.getX(i);
    instancePositions[i * 3 + 1] = positionAttribute.getY(i);
    instancePositions[i * 3 + 2] = positionAttribute.getZ(i);
  }

  const spriteMaterial = new THREE.SpriteNodeMaterial({
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending
  });

  const dist = uv().distance(vec2(0.5, 0.5));
  const alphaNode = dist.smoothstep(0.5, 0.0);

  spriteMaterial.colorNode = color(0xff0000);
  spriteMaterial.opacityNode = alphaNode;

  const instancePositionNode = instancedBufferAttribute(
    new THREE.InstancedBufferAttribute(instancePositions, 3)
  );

  spriteMaterial.positionNode = instancePositionNode;
  spriteMaterial.scaleNode = vec2(.05);

  // const spriteGeometry = new THREE.PlaneGeometry(0.08, 0.08);
  // const instancedSprites = new THREE.Mesh(spriteGeometry, spriteMaterial);
  const instancedSprites = new THREE.Sprite(spriteMaterial); // 상기 코드와 동일
  instancedSprites.count = count;

  this.scene.add(instancedSprites);
}

두번째 방식은 ...

private setupModel() {
  const knotGeometry = new THREE.TorusKnotGeometry(1, 0.3, 256, 64);
  const positionAttribute = knotGeometry.attributes.position;
  const count = positionAttribute.count; // 정점 개수

  const spriteMaterial = new THREE.SpriteNodeMaterial({
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending
  });

  const dist = uv().distance(vec2(0.5, 0.5));
  const alphaNode = dist.smoothstep(0.5, 0.0);

  spriteMaterial.colorNode = color(0xff0000);
  spriteMaterial.opacityNode = alphaNode;
  spriteMaterial.vertexNode = billboarding({
    horizontal: true,
    vertical: true,
    horizontalRotation: false
  });

  const spriteGeometry = new THREE.PlaneGeometry(0.05, 0.05);
  const instancedSprites = new THREE.InstancedMesh(spriteGeometry, spriteMaterial, count);

  const dummy = new THREE.Object3D();
  const vertex = new THREE.Vector3();

  for (let i = 0; i < count; i++) {
    vertex.fromBufferAttribute(positionAttribute, i);

    dummy.position.copy(vertex);
    dummy.updateMatrix();

    instancedSprites.setMatrixAt(i, dummy.matrix);
  }
  instancedSprites.instanceMatrix.needsUpdate = true;

  this.scene.add(instancedSprites);
}

세번째 방식은 다음과 같습니다.

private setupModel() {
  const knotGeometry = new THREE.TorusKnotGeometry(1, 0.3, 256, 64);
  const positionAttr = knotGeometry.attributes.position;
  const count = positionAttr.count;

  const positionArray = new Float32Array(count * 3);
  for (let i = 0; i < count; i++) {
    positionArray[i * 3] = positionAttr.getX(i);
    positionArray[i * 3 + 1] = positionAttr.getY(i);
    positionArray[i * 3 + 2] = positionAttr.getZ(i);
  }

  const positionStorageBuffer = new THREE.StorageBufferAttribute(positionArray, 3);
  const positionStorageNode = storage(positionStorageBuffer, 'vec3', count);

  const spriteMaterial = new THREE.SpriteNodeMaterial({
    transparent: true,
    depthWrite: false,
    blending: THREE.AdditiveBlending
  });

  const dist = uv().distance(vec2(0.5, 0.5));
  const alphaNode = dist.smoothstep(0.5, 0.0);

  spriteMaterial.colorNode = color(0xff0000);
  spriteMaterial.opacityNode = alphaNode;

  spriteMaterial.positionNode = positionStorageNode.element(THREE.TSL.instanceIndex);

  const spriteGeometry = new THREE.PlaneGeometry(0.08, 0.08);
  const instancedSprites = new THREE.Mesh(spriteGeometry, spriteMaterial);

  instancedSprites.count = count;

  this.scene.add(instancedSprites);
}

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는 보안정책 상 정확한 값은 얻기 어렵다고 합니다.