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

물리적 광원에 대한 개별 노드 지정 (TSL 방식)

three.js 쉐이더 언어인 TSL에서 물리적 광원에 대한 개별 노드를 지정하는 방식을 정리해 봅니다. TSL을 통해 표현하고자 하는 재질은 다음과 같습니다.

코드는 다음과 같습니다.

const geometry = new THREE.IcosahedronGeometry(1, 64);
const material = new THREE.MeshStandardNodeMaterial({
  color: "black",
  // wireframe: true,
});
const mesh = new THREE.Mesh(geometry, material)

const path = './Metal053B_2K-JPG';

const texColor = new THREE.TextureLoader().load(`${path}/Metal053B_2K-JPG_Color.jpg`);
material.colorNode = texture(texColor);

const texNormal = new THREE.TextureLoader().load(`${path}/Metal053B_2K-JPG_NormalGL.jpg`);
material.normalNode = normalMap(texture(texNormal), float(1.));

const texMetalness = new THREE.TextureLoader().load(`${path}/Metal053B_2K-JPG_Metalness.jpg`);
material.metalnessNode = mul(texture(texMetalness), 1.);

const texRoughness = new THREE.TextureLoader().load(`${path}/Metal053B_2K-JPG_Roughness.jpg`);
material.roughnessNode = mul(texture(texRoughness), float(0.7));

광원 노드에 집중하기 위해서 텍스쳐 데이터를 사용했습니다. 텍스쳐 본연의 표현을 위해 재질의 기본 색상을 블랙으로 지정했구요. 사용한 노드는 colorNode, normalNode, metalnessNode, roughnessNode입니다.

광원에 대한 노드는 아니지만 Displacement에 대한 노드를 알아보기 위해 표현하고자 하는 재질은 다음과 같습니다.

코드는 다음과 같습니다.

const geometry = new THREE.IcosahedronGeometry(1, 64);
const material = new THREE.MeshStandardNodeMaterial({
  color: "black",
  // wireframe: true,
});
const mesh = new THREE.Mesh(geometry, material)

const path = './Rock058_2K-JPG';

...

const texAO = new THREE.TextureLoader().load(`${path}/Rock058_2K-JPG_AmbientOcclusion.jpg`);
geometry.setAttribute('uv2', new THREE.BufferAttribute(geometry.attributes.uv.array, 2));
material.aoNode = mul(texture(texAO), float(1.));

const texDisplacement = new THREE.TextureLoader().load(`${path}/Rock058_2K-JPG_Displacement.jpg`);
const displacementNode = texture(texDisplacement);
const displaceStrength = 0.3;
const displacementValue = displacementNode.r.sub(0.5).mul(displaceStrength);
const newPosition = positionWorld.add(normalLocal.mul(displacementValue));
material.positionNode = newPosition;

Displacement 표현을 위해서는 Vertex Shader에 해당하는 positionNode를 이용해야 합니다. 추가로 AO 노드에 대한 사용 코드도 보입니다.

R3F Cookbook

R3F는 React 기반에서 three.js를 매우 직관적이고 사용하기 쉬운 컴포넌트 레벨로 개발할 수 있는 패키지입니다. 아래의 영상은 R3F에서 제공하는 컴포넌트, 클래스 등과 R3F를 위한 보다 더 발전된 컴포넌트를 제공하는 drei에 대한 내용을 설명합니다. 이 영상 제작의 목적은 궁극적으로 drei의 컴포넌트를 이해하고 이러한 컴포넌트를 직접 개발하기 위한 지식을 얻기 위함입니다.

위의 영상에 더해 지속적으로 내용이 업데이트되므로 해당 채널에 방문하여 참고하시기 바랍니다.

GLSL 퀵 레퍼런스

어떤 지점(p)에서 Ray(r)에 가장 가까운 r 위의 좌표 얻기

struct ray {
  vec3 o, d;
};

vec3 ClosestPoint(ray r, vec3 p) {
  return r.o + max(0., dot(p-r.o, r.d)) * r.d;
}

위의 ClosePoint를 이용하면 ray와 어떤 지점에 대한 최단 거리를 아래 함수를 이용해 구할 수 있다.

float DistRay(ray r, vec3 p) {
  return length(p - ClosestPoint(r, p));
}

선 그리기 함수

float line(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a, ba = b - a;
  float t = clamp(dot(pa, ba)/dot(ba, ba), 0., 1.);
  vec2 c = a + ba * t;
  float d = length(c - p);
  return smoothstep(fwidth(d), 0., d - .001);
}

0 ~ 1 사이의 값을 갖는 간단한 노이즈 함수

float random (in vec2 st) {
    return fract(sin(dot(st.xy,
        vec2(12.9898,78.233)))*
        43758.5453123);
}

// Based on Morgan McGuire @morgan3d
// https://www.shadertoy.com/view/4dS3Wd
float noise (in vec2 st) {
    vec2 i = floor(st);
    vec2 f = fract(st);

    // Four corners in 2D of a tile
    float a = random(i);
    float b = random(i + vec2(1.0, 0.0));
    float c = random(i + vec2(0.0, 1.0));
    float d = random(i + vec2(1.0, 1.0));

    vec2 u = f * f * (3.0 - 2.0 * f);

    return mix(a, b, u.x) +
            (c - a)* u.y * (1.0 - u.x) +
            (d - b) * u.x * u.y;
}

-1 ~ 1 사이의 값을 갖는 Simplex 노이즈 함수

// Some useful functions
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
vec3 permute(vec3 x) { return mod289(((x*34.0)+1.0)*x); }

//
// Description : GLSL 2D simplex noise function
//      Author : Ian McEwan, Ashima Arts
//  Maintainer : ijm
//     Lastmod : 20110822 (ijm)
//     License :
//  Copyright (C) 2011 Ashima Arts. All rights reserved.
//  Distributed under the MIT License. See LICENSE file.
//  https://github.com/ashima/webgl-noise
//
float snoise(vec2 v) {

    // Precompute values for skewed triangular grid
    const vec4 C = vec4(0.211324865405187,
                        // (3.0-sqrt(3.0))/6.0
                        0.366025403784439,
                        // 0.5*(sqrt(3.0)-1.0)
                        -0.577350269189626,
                        // -1.0 + 2.0 * C.x
                        0.024390243902439);
                        // 1.0 / 41.0

    // First corner (x0)
    vec2 i  = floor(v + dot(v, C.yy));
    vec2 x0 = v - i + dot(i, C.xx);

    // Other two corners (x1, x2)
    vec2 i1 = vec2(0.0);
    i1 = (x0.x > x0.y)? vec2(1.0, 0.0):vec2(0.0, 1.0);
    vec2 x1 = x0.xy + C.xx - i1;
    vec2 x2 = x0.xy + C.zz;

    // Do some permutations to avoid
    // truncation effects in permutation
    i = mod289(i);
    vec3 p = permute(
            permute( i.y + vec3(0.0, i1.y, 1.0))
                + i.x + vec3(0.0, i1.x, 1.0 ));

    vec3 m = max(0.5 - vec3(
                        dot(x0,x0),
                        dot(x1,x1),
                        dot(x2,x2)
                        ), 0.0);

    m = m*m ;
    m = m*m ;

    // Gradients:
    //  41 pts uniformly over a line, mapped onto a diamond
    //  The ring size 17*17 = 289 is close to a multiple
    //      of 41 (41*7 = 287)

    vec3 x = 2.0 * fract(p * C.www) - 1.0;
    vec3 h = abs(x) - 0.5;
    vec3 ox = floor(x + 0.5);
    vec3 a0 = x - ox;

    // Normalise gradients implicitly by scaling m
    // Approximation of: m *= inversesqrt(a0*a0 + h*h);
    m *= 1.79284291400159 - 0.85373472095314 * (a0*a0+h*h);

    // Compute final noise value at P
    vec3 g = vec3(0.0);
    g.x  = a0.x  * x0.x  + h.x  * x0.y;
    g.yz = a0.yz * vec2(x1.x,x2.x) + h.yz * vec2(x1.y,x2.y);
    return 130.0 * dot(m, g);
}

좀더 고른 분포의 랜던 hash

uvec2 murmurHash21(uint src) {
  const uint M = 0x5bd1e995u;
  uvec2 h = uvec2(1190494759u, 2147483647u);
  src *= M;
  src ^= src>>24u;
  src *= M;
  h *= M;
  h ^= src;
  h ^= h>>13u;
  h *= M;
  h ^= h>>15u;
  return h;
}

// 2 outputs, 1 input
vec2 hash21(float src) {
  uvec2 h = murmurHash21(floatBitsToUint(src));
  return uintBitsToFloat(h & 0x007fffffu | 0x3f800000u) - 1.0;
}

smoothstep의 보간식

아래의 a와 b는 동일한 값이다.

float f = uv.x;
float a = smoothstep(0., 1., f);
float b = f * f * (3. - 2. * f);

위의 보간식을 cubic Hermite curve(큐빅 헤르미트 곡선)라고 한다. 0~1 사이의 영역에서 시작과 끝을 좀더 편평하게 만들어주는 quintic interpolation cuver(퀸틱 보간 곡선)에 대한 코드는 다음과 같다.

float f = uv.x;
float b = f * f * f * (6. * f * f - 15. * f + 10.);

modelMatrix로 변환된 normal 얻기

varying vec3 vNormal;

...

vNormal = mat3(transpose(inverse(modelMatrix))) * normal;

프레그먼트 쉐이더에서는 받은 vNormal을 반드시 정규화(normalize)해야 한다.

3초 주기로 0 ~ 1 사이의 연속된 값 얻기

uniform float u_time; // 0, 1, 2, ...의 값이며 각각 0초, 1초, 2초, ...를 나타냄

...

float period = 3.; // 3초 주기
float t = mod(u_time, period) / period; // 0 ~ 1 사이의 연속된 값

// sin 함수에 3.1415를 곱하는 이유는 sin 함수의 한 주기가 360도이기 때문임
vec3 color = mix(vec3(0), vec3(1), abs(sin(3.1415 * t)));
// or
vec3 color = mix(vec3(0), vec3(1), sin(3.1415 * t) * .5 + .5); 

원하는 각도를 이루는 선

아래의 이미지는 45도를 이루는 선인데, 이처럼 원하는 각도를 이루는 선을 만들기 위한 코드이다.

uniform vec3 uResolution;
uniform float uTime;
uniform vec4 uMouse;

#define PI (3.141592)

void main() {
  vec2 st = gl_FragCoord.xy / uResolution.xy;
  st = (gl_FragCoord.xy - .5 * uResolution.xy) / uResolution.y;

  float w = fwidth(st.y);
  float a = (45.) * PI / 180.0;
  float d = dot(st, vec2(cos(a), sin(a)));
  d = smoothstep(-w, w, abs(d));

  gl_FragColor = vec4(vec3(1. - d), 1.);
}

다각형 그리기

uniform vec3 uResolution;
uniform float uTime;
uniform vec4 uMouse;

#define PI 3.14159265359
#define TWO_PI 6.28318530718

void main(){
  vec2 st = gl_FragCoord.xy/uResolution.xy * 2. - 1.; //-1-1
  st.x *= uResolution.x/uResolution.y;
  vec3 color = vec3(0.0);
  float d = 0.0;

  // Number of sides of your shape
  int N = 6;
  // int N = int(floor(mod(uTime * 10., 20.))) + 3;

  // Angle and radius from the current pixel
  float a = atan(st.x,st.y)+PI;
  float r = TWO_PI/float(N);

  // Shaping function that modulate the distance
  d = cos(floor(.5+a/r)*r-a)*length(st);

  // color = vec3(d); // Distance Field
  color = vec3(1.0-step(.5, d)); // Fill
  // color = vec3(step(.5,d) * step(d,.51)); // Only outline

  gl_FragColor = vec4(color,1.0);
}

GLSL 코드

다음과 같은 결과를 프레그먼트 쉐이더로 작성한다면 … ? 즉, 화면의 반을 가르고 가른 영역의 중심을 원점으로 삼으며 원점을 기준으로 gl_FragCoord 지점에 대한 좌표(x,y)에 대한 각도(atan(y,x))에 대해 왼쪽 영역은 cos 값으로, 오른쪽 영역은 sin 값으로 채움.

방식은 여러가지겠지만 여기서는 2가지 구현 코드를 언급함. 첫번째는 제시된 문제를 그대로 해석해 풀이한 것.

uniform vec2 iResolution;

void main2() {
    vec2 uv = gl_FragCoord.xy / iResolution;
  
    vec2 origin = uv.x < 0.5 ? vec2(0.25, 0.5) : vec2(0.75, 0.5);
    vec2 v = uv - origin;
  
    float angle = atan(v.y, v.x);
    float c = cos(angle);
    float s = sin(angle);
    
    vec3 color = uv.x < 0.5 ? vec3(c) : vec3(s);
    
    gl_FragColor = vec4(color, 1.0);
}

두번째는 삼각함수의 원리를 이해하고 상황에 맞게 최적화해 구현한 것.

uniform vec2 iResolution;

void main() {
  vec2 uv = gl_FragCoord.xy / iResolution.xy;

  vec2 normal = uv.x > .5 ? 
    normalize(uv - vec2(0.75, 0.5)) : 
    normalize(uv - vec2(0.25, 0.5));
  
  float t = uv.x > .5 ?  normal.y : normal.x;

  gl_FragColor = vec4(vec3(t), 1.0);
}