리토폴로지

Retopology

스캔이나 스컬프트로 만들어진 지저분한 고밀도 메시 위에, 같은 형태를 따르는 깔끔한 쿼드 메시를 새로 얹는 작업.

다른 이름: RetopoManual retopologyAuto-retopo
···
html
<canvas id="rp-canvas"></canvas><div class="rp-caps"><span id="rp-messy">scan · dense &amp; messy</span><span id="rp-clean">retopo · clean quads</span></div>
css
#rp-canvas{width:100%;height:100%;display:block}
.rp-caps{position:absolute;left:0;right:0;bottom:4%;display:grid;place-items:center;font-size:clamp(9px,2.4vmin,12px);color:var(--muted)}
.rp-caps span{grid-area:1/1;transition:opacity .3s linear}
js
const canvas = document.getElementById('rp-canvas');
const ctx = canvas.getContext('2d');
const cs = getComputedStyle(document.documentElement);
const colFg = cs.getPropertyValue('--fg').trim() || '#222';
const colMuted = cs.getPropertyValue('--muted').trim() || '#888';

function resize() {
  const r = canvas.getBoundingClientRect();
  canvas.width = Math.round(r.width * devicePixelRatio);
  canvas.height = Math.round(r.height * devicePixelRatio);
}
addEventListener('resize', resize); resize();

const N_BLOB = 18;
const blobR = [];
for (let i = 0; i < N_BLOB; i++) blobR.push(0.8 + Math.sin(i * 1.7) * 0.08 + Math.cos(i * 0.6) * 0.06);
function blobPoint(a) {
  const idx = (a / (Math.PI * 2)) * N_BLOB;
  const i0 = Math.floor(idx) % N_BLOB, i1 = (i0 + 1) % N_BLOB;
  const f = idx - Math.floor(idx);
  const r = blobR[i0] * (1 - f) + blobR[i1] * f;
  return [Math.cos(a) * r, Math.sin(a) * r];
}
const messyPts = [];
for (let i = 0; i < 55; i++) {
  const a = Math.random() * Math.PI * 2;
  const rr = 0.12 + Math.random() * 0.82;
  const [bx, by] = blobPoint(a);
  messyPts.push([bx * rr, by * rr]);
}
const messyLines = [];
for (let i = 0; i < messyPts.length; i++) {
  const dists = messyPts
    .map((p, j) => [j, Math.hypot(p[0] - messyPts[i][0], p[1] - messyPts[i][1])])
    .filter(([j]) => j !== i)
    .sort((a, b) => a[1] - b[1]);
  for (let k = 0; k < 3; k++) messyLines.push([i, dists[k][0]]);
}

function ease(x) { return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; }

function draw(t) {
  const w = canvas.width, h = canvas.height;
  ctx.clearRect(0, 0, w, h);
  const cx = w / 2, cy = h / 2;
  const scale = Math.min(w, h) * 0.42;

  const cycle = 6000;
  const local = (t % cycle) / cycle;
  let clean;
  if (local < 0.4) clean = 0;
  else if (local < 0.5) clean = ease((local - 0.4) / 0.1);
  else if (local < 0.9) clean = 1;
  else clean = 1 - ease((local - 0.9) / 0.1);

  ctx.globalAlpha = 0.35;
  ctx.strokeStyle = colFg;
  ctx.lineWidth = Math.max(1, scale * 0.006);
  ctx.beginPath();
  for (let i = 0; i <= 64; i++) {
    const a = (i / 64) * Math.PI * 2;
    const [x, y] = blobPoint(a);
    const px = cx + x * scale, py = cy + y * scale;
    if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.closePath(); ctx.stroke();

  ctx.globalAlpha = (1 - clean) * 0.85;
  ctx.strokeStyle = colMuted;
  ctx.lineWidth = Math.max(1, scale * 0.004);
  ctx.beginPath();
  for (const [i, j] of messyLines) {
    ctx.moveTo(cx + messyPts[i][0] * scale, cy + messyPts[i][1] * scale);
    ctx.lineTo(cx + messyPts[j][0] * scale, cy + messyPts[j][1] * scale);
  }
  ctx.stroke();

  ctx.globalAlpha = clean;
  ctx.strokeStyle = colFg;
  ctx.lineWidth = Math.max(1, scale * 0.006);
  const RINGS = 4, SPOKES = 14;
  for (let ring = 1; ring <= RINGS; ring++) {
    const frac = ring / RINGS;
    ctx.beginPath();
    for (let i = 0; i <= 64; i++) {
      const a = (i / 64) * Math.PI * 2;
      const [x, y] = blobPoint(a);
      const px = cx + x * frac * scale, py = cy + y * frac * scale;
      if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
    }
    ctx.stroke();
  }
  for (let s = 0; s < SPOKES; s++) {
    const a = (s / SPOKES) * Math.PI * 2;
    const [x, y] = blobPoint(a);
    ctx.beginPath();
    ctx.moveTo(cx, cy);
    ctx.lineTo(cx + x * scale, cy + y * scale);
    ctx.stroke();
  }
  ctx.globalAlpha = 1;

  document.getElementById('rp-messy').style.opacity = String(1 - clean);
  document.getElementById('rp-clean').style.opacity = String(clean);
  requestAnimationFrame(draw);
}
requestAnimationFrame(draw);

3D 스캔이나 스컬프트 도구(ZBrush 등)로 만든 결과물은 수백만 개의 정점이 아무 흐름 없이 흩어져 있어서, 형태는 정확해도 토폴로지가 최악인 경우가 많습니다. 애니메이션·게임에는 그대로 못 씁니다 — 구부러져야 할 부위가 예측 불가능하게 일그러지기 때문입니다.

리토폴로지는 이 고밀도 표면을 "가이드"로 삼아 그 위에 훨씬 적은 수의, 형태의 흐름을 따르는 쿼드 메시를 새로 그리는 작업입니다. 결과물의 실루엣과 부피는 원본과 거의 같지만, 내부 구조(토폴로지)는 완전히 다시 설계됩니다. 이렇게 만들어진 저밀도 메시는 서브디비전 서피스의 케이지로도 바로 쓸 수 있습니다.

Blender의 Retopoflow, Maya의 Quad Draw, ZBrush의 ZRemesher는 모두 이 작업을 돕는 도구입니다 — ZRemesher처럼 자동으로 그럴듯한 흐름을 추정해 주는 것도 있고, 손으로 한 면씩 그려야 하는 수동 방식도 있습니다. 얼굴처럼 정교한 애니메이션이 필요한 부위는 자동보다 수동이 여전히 선호됩니다.

언제 쓰나

스캔·스컬프트 결과를 애니메이션이나 게임 파이프라인에 넣기 전에 거의 항상 거쳐야 하는 단계입니다.