레이마칭

Raymarching

삼각형 메시 없이, 화면의 모든 픽셀에서 광선을 조금씩 전진시켜 수학 함수로 정의된 형태를 직접 그리는 기법.

다른 이름: Signed distance fieldSDFSphere tracingMetaballs
···
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

const vertexShader = ['void main() { gl_Position = vec4(position, 1.0); }'].join('\n');

const fragmentShader = [
  'precision highp float;',
  'uniform vec2 uResolution;',
  'uniform float uTime;',
  'float sphere(vec3 p, vec3 c, float r) { return length(p - c) - r; }',
  'float smin(float a, float b, float k) {',
  '  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);',
  '  return mix(b, a, h) - k * h * (1.0 - h);',
  '}',
  'float map(vec3 p) {',
  '  float t = uTime;',
  '  vec3 c1 = vec3(sin(t * 0.8) * 1.1, cos(t * 0.6) * 0.6, 0.0);',
  '  vec3 c2 = vec3(cos(t * 0.7) * 1.0, sin(t * 0.9) * 0.7, 0.0);',
  '  vec3 c3 = vec3(sin(t * 0.5 + 2.0) * 0.9, cos(t * 0.4 + 1.0) * 0.5, 0.0);',
  '  float d1 = sphere(p, c1, 0.62);',
  '  float d2 = sphere(p, c2, 0.5);',
  '  float d3 = sphere(p, c3, 0.55);',
  '  return smin(smin(d1, d2, 0.4), d3, 0.4);',
  '}',
  'vec3 normalAt(vec3 p) {',
  '  vec2 e = vec2(0.001, 0.0);',
  '  return normalize(vec3(',
  '    map(p + e.xyy) - map(p - e.xyy),',
  '    map(p + e.yxy) - map(p - e.yxy),',
  '    map(p + e.yyx) - map(p - e.yyx)',
  '  ));',
  '}',
  'void main() {',
  '  vec2 uv = (gl_FragCoord.xy - 0.5 * uResolution) / uResolution.y;',
  '  vec3 ro = vec3(0.0, 0.0, 3.2);',
  '  vec3 rd = normalize(vec3(uv, -1.4));',
  '  float total = 0.0;',
  '  vec3 col = vec3(0.03, 0.02, 0.07);',
  '  for (int i = 0; i < 48; i++) {',
  '    vec3 p = ro + rd * total;',
  '    float d = map(p);',
  '    if (d < 0.001) {',
  '      vec3 n = normalAt(p);',
  '      vec3 lightDir = normalize(vec3(0.6, 0.7, 0.5));',
  '      float diff = max(dot(n, lightDir), 0.0);',
  '      float fresnel = pow(1.0 - max(dot(n, -rd), 0.0), 2.5);',
  '      vec3 base = mix(vec3(0.25, 0.35, 0.95), vec3(0.9, 0.35, 0.65), uv.x + 0.5);',
  '      col = base * (0.25 + diff * 0.85) + fresnel * vec3(0.6, 0.8, 1.0);',
  '      break;',
  '    }',
  '    if (total > 7.0) break;',
  '    total += d;',
  '  }',
  '  gl_FragColor = vec4(col, 1.0);',
  '}',
].join('\n');

const mat = new THREE.ShaderMaterial({
  uniforms: {
    uResolution: { value: new THREE.Vector2(innerWidth, innerHeight) },
    uTime: { value: 0 },
  },
  vertexShader,
  fragmentShader,
});
const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat);
quad.frustumCulled = false;
scene.add(quad);

function resize() {
  renderer.setSize(innerWidth, innerHeight);
  mat.uniforms.uResolution.value.set(innerWidth, innerHeight);
}
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  mat.uniforms.uTime.value = t * 0.001;
  renderer.render(scene, camera);
});

일반적인 3D 렌더링은 정점으로 이뤄진 메시를 화면에 투영합니다. 레이마칭은 반대로 접근합니다: 화면의 각 픽셀에서 카메라를 지나는 광선을 정의하고, "이 지점에서 가장 가까운 표면까지의 거리"를 돌려주는 signed distance function(SDF)을 반복 호출하면서 그 거리만큼씩 광선을 전진시킵니다. 거리가 거의 0이 되면 표면에 닿은 것이므로 멈추고, 그 지점에서 조명을 계산합니다.

이 데모의 SDF는 구 3개(sphere())를 부드럽게 합치는 smin()(smooth minimum) 함수로 정의됩니다 — 일반 min()이 두 형태를 딱딱하게 이어붙인다면, smin()은 경계를 부드럽게 녹여 붙여서 메타볼(액체 방울이 서로 끌어당겨 뭉치는 듯한) 효과를 냅니다. 법선은 그 지점 주변 6방향의 SDF 값 차이(유한 차분)로 근사해서 조명에 씁니다.

메시로는 표현하기 번거로운 부드러운 블롭·유체·프랙탈을 통째로 셰이더 하나로 그릴 수 있다는 게 장점이지만, 픽셀마다 SDF를 수십 번씩 호출하므로 연산 비용이 큽니다. 그래서 이 사이트에서는 스텝 수를 제한하고 캔버스를 fullscreen quad(THREE.PlaneGeometry(2,2))에 그려 화면 전체를 한 번의 드로우콜로 처리합니다.

언제 쓰나

메타볼·액체·구름·프랙탈처럼 폴리곤으로 표현하기 어려운 유기적 형태를 셰이더 하나로 그릴 때.