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에 대한 완전한 지오메트리 구성이 된 것을 확인할 수 있습니다.

가장자리 마스크 (Edge Mask)

2가지 효과적인 방식 중 첫번째는 다음과 같다.

결과는 아래와 같고 Cycles 렌더링에서만 사용 가능하다.

다른 방식은 다음과 같다.

더 나은 결과를 제공하며 결과는 아래와 같고 Cycles 뿐만 아니라 Eevee에서도 작동하지만 문제가 많아 이 역시 Cycles에서만 사용하는게 맞을듯하다.

지오서비스웹의 지오코딩 결과에 PNU 코드 정보가 제공됩니다.

지오서비스웹에서 두번째로 가장 많이 사용되는 기능이 지오코딩입니다. 그간 받은 피드백 중 지오코딩 결과에 좌표 뿐만 아니라 PNU 코드가 필요하다는 이용자 분들이 계셨고 이에 대한 내용을 반영하였습니다.

PNU 코드는 지번주소에 대한 코드인데, 특히 도로명주소에 대한 PNU 코드도 제공될 수 있도록 하였습니다. PNU 코드는 다음과 같이 활용할 수 있습니다.

  • 지적도 및 GIS 데이터 연계: PNU 코드는 지적도, 도시계획, 부동산 데이터 등에서 필지(토지)를 정확히 식별하는 데 사용됩니다. 예를 들어, QGIS 등 GIS 소프트웨어에서 PNU 코드를 필터로 활용해 특정 토지를 조회하거나 지도상에서 시각화할 수 있습니다.
  • 주소 변환 및 데이터 전처리: 도로명주소, 지번주소 등 다양한 주소 형식을 PNU 코드로 변환하거나, 실거래가·부동산 데이터에서 PNU 코드를 추출해 분석에 활용합니다.
  • 행정·공공기관 시스템 연계: PNU 코드는 행정표준코드관리시스템 등에서 법정동, 읍면동, 지번 등 상세 정보를 조회하는 데도 활용됩니다.

사용자 인터페이스는 크게 달라진 부분은 없습니다. PNU 라는 체크 박스를 하나 추가해서 체크되면 PNU 코드값도 함께 저장되도록 하였습니다. PNU 저장은 기본적으로 체크되어 있습니다.

위의 내용처럼 설정하고 지오코딩을 실행한 결과를 들여다보면 다음처럼 _PNU라는 이름의 컬럼이 보이고 19자리의 코드값이 저장된 것을 확인할 수 있습니다.

TSL Tips

normalNode에 대한 적용을 위해 bumpMap 노드를 사용

const diffuseTex = loader.load('./brick_diffuse.jpg');
diffuseTex.colorSpace = THREE.SRGBColorSpace;

const bumpTex = loader.load('./brick_bump.jpg');

const wallMat = new THREE.MeshStandardNodeMaterial();

wallMat.colorNode = texture(diffuseTex);
wallMat.normalNode = bumpMap(texture(bumpTex), float(5));

각도와 거리에 대한 가하학적 계산식

GPU를 통해 쓰고 읽을 수 있는 텍스쳐

/* 필요한 API 임포트 */
import { texture, textureStore, Fn, instanceIndex, float, uvec2, vec4, time } from 'three/tsl';

/* 스토리지 텍스쳐 생성 */
const width = 512, height = 512;
const storageTexture = new THREE.StorageTexture(width, height);

/* 텍스쳐 내용 생성 함수 정의 */
const computeTexture = Fn(({ storageTexture }) => {
  const posX = instanceIndex.mod(width);
  const posY = instanceIndex.div(width);
  const indexUV = uvec2(posX, posY);

  const x = float(posX).div(50.0);
  const y = float(posY).div(50.0);

  const v1 = x.sin();
  const v2 = y.sin();
  const v3 = x.add(y.add(time.mul(1))).sin();
  const v4 = x.mul(x).add(y.mul(y)).sqrt().add(5.0).sin();
  const v = v1.add(v2, v3, v4);

  const r = v.sin();
  const g = v.add(Math.PI).sin();
  const b = v.add(Math.PI).sub(0.5).sin();

  textureStore(storageTexture, indexUV, vec4(r, g, b, 1)).toWriteOnly();
});

/* 텍스쳐 내용 생성 함수 실행 */
const computeNode = computeTexture({ storageTexture }).compute(width * height);
this._renderer.compute(computeNode);

/* 텍스쳐 활용 */
const material = new THREE.MeshBasicNodeMaterial({ color: 0x00ff00 });
material.colorNode = texture(storageTexture);