스플릿 토닝

Split toning

그림자에는 한 가지 색조를, 하이라이트에는 다른 색조를 각각 얹어 사진에 톤 대비를 주는 기법.

다른 이름: Split tone하이라이트·섀도 컬러
···
html
<div class="wrap">
  <div class="stage"><canvas id="c" width="300" height="180"></canvas></div>
  <div class="hud">
    <span class="sw"><i id="swS"></i>그림자</span>
    <span class="sw"><i id="swH"></i>하이라이트</span>
  </div>
</div>
css
.wrap{position:absolute;inset:0;padding:clamp(8px,3vmin,20px);display:flex;flex-direction:column;gap:6px}
.stage{position:relative;flex:1;min-height:0}
canvas{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;border-radius:10px;border:1px solid var(--line);background:#0a0a0a}
.hud{display:flex;justify-content:center;gap:16px;font:700 clamp(9px,2.2vmin,11px)/1 ui-monospace,monospace;color:var(--muted)}
.sw{display:flex;align-items:center;gap:6px}
.sw i{display:inline-block;width:12px;height:12px;border-radius:50%;border:1px solid var(--line)}
js
var W = 300, H = 180;
var c = document.getElementById('c'), ctx = c.getContext('2d');

function paintPhoto(cx, w, h) {
  var g = cx.createLinearGradient(0, 0, 0, h * 0.62);
  g.addColorStop(0, '#5b86c9'); g.addColorStop(0.55, '#bcd6ea'); g.addColorStop(1, '#f5e0ad');
  cx.fillStyle = g; cx.fillRect(0, 0, w, h);
  var sx = w * 0.74, sy = h * 0.22, sr = h * 0.11;
  var sg = cx.createRadialGradient(sx, sy, 0, sx, sy, sr * 3.2);
  sg.addColorStop(0, 'rgba(255,247,214,0.9)'); sg.addColorStop(1, 'rgba(255,247,214,0)');
  cx.fillStyle = sg; cx.fillRect(0, 0, w, h * 0.62);
  cx.fillStyle = '#fff8df'; cx.beginPath(); cx.arc(sx, sy, sr, 0, 7); cx.fill();
  cx.fillStyle = '#6f9152';
  cx.beginPath(); cx.moveTo(0, h * 0.64);
  for (var x = 0; x <= w; x += 14) cx.lineTo(x, h * 0.64 - Math.sin(x * 0.014) * h * 0.05 - Math.sin(x * 0.032 + 1.2) * h * 0.02);
  cx.lineTo(w, h); cx.lineTo(0, h); cx.closePath(); cx.fill();
  cx.fillStyle = '#405f38';
  cx.beginPath(); cx.moveTo(0, h * 0.79);
  for (var x2 = 0; x2 <= w; x2 += 14) cx.lineTo(x2, h * 0.79 - Math.sin(x2 * 0.022 + 0.6) * h * 0.035);
  cx.lineTo(w, h); cx.lineTo(0, h); cx.closePath(); cx.fill();
  var tx = w * 0.2, ty = h * 0.8;
  cx.strokeStyle = '#3a2a1c'; cx.lineWidth = Math.max(1.5, w * 0.008);
  cx.beginPath(); cx.moveTo(tx, ty); cx.lineTo(tx - w * 0.01, ty - h * 0.13); cx.stroke();
  cx.fillStyle = '#2e4a28';
  for (var i = 0; i < 5; i++) { cx.beginPath(); cx.arc(tx + (i - 2) * w * 0.013, ty - h * 0.15 - (i % 2) * h * 0.018, w * 0.028, 0, 7); cx.fill(); }
  cx.fillStyle = '#1e1e1c';
  cx.beginPath(); cx.arc(w * 0.56, h * 0.745, h * 0.018, 0, 7); cx.fill();
  cx.fillRect(w * 0.555, h * 0.76, w * 0.01, h * 0.06);
  var img = cx.getImageData(0, 0, w, h), d = img.data;
  for (var p = 0; p < d.length; p += 4) { var n = (Math.random() - 0.5) * 10; d[p] += n; d[p + 1] += n; d[p + 2] += n; }
  cx.putImageData(img, 0, 0);
}
paintPhoto(ctx, W, H);
var orig = ctx.getImageData(0, 0, W, H);
function hue2rgb(p, q, t) {
  if (t < 0) t += 1; if (t > 1) t -= 1;
  if (t < 1 / 6) return p + (q - p) * 6 * t;
  if (t < 1 / 2) return q;
  if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
  return p;
}
function hslToRgb(h, s, l) {
  var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  var p = 2 * l - q;
  return [hue2rgb(p, q, h + 1 / 3) * 255, hue2rgb(p, q, h) * 255, hue2rgb(p, q, h - 1 / 3) * 255];
}
var swS = document.getElementById('swS'), swH = document.getElementById('swH');
var t = 0;
function frame() {
  t += 0.006;
  var shadowHue = (190 + Math.sin(t) * 24 + 360) % 360;
  var highHue = (shadowHue + 165) % 360;
  var shadowRGB = hslToRgb(shadowHue / 360, 0.55, 0.5);
  var highRGB = hslToRgb(highHue / 360, 0.6, 0.58);
  swS.style.background = 'rgb(' + shadowRGB[0] + ',' + shadowRGB[1] + ',' + shadowRGB[2] + ')';
  swH.style.background = 'rgb(' + highRGB[0] + ',' + highRGB[1] + ',' + highRGB[2] + ')';
  var out = ctx.createImageData(W, H);
  var id = orig.data, od = out.data;
  for (var i = 0; i < id.length; i += 4) {
    var lum = (0.2126 * id[i] + 0.7152 * id[i + 1] + 0.0722 * id[i + 2]) / 255;
    var shadowW = Math.max(0, 1 - lum * 1.7) * 0.4;
    var highW = Math.max(0, (lum - 0.35) / 0.65) * 0.4;
    var baseW = 1 - shadowW - highW;
    od[i] = Math.min(255, Math.max(0, id[i] * baseW + shadowRGB[0] * shadowW + highRGB[0] * highW));
    od[i + 1] = Math.min(255, Math.max(0, id[i + 1] * baseW + shadowRGB[1] * shadowW + highRGB[1] * highW));
    od[i + 2] = Math.min(255, Math.max(0, id[i + 2] * baseW + shadowRGB[2] * shadowW + highRGB[2] * highW));
    od[i + 3] = 255;
  }
  ctx.putImageData(out, 0, 0);
  requestAnimationFrame(frame);
}
frame();

원리는 휘도를 기준으로 픽셀을 나누는 것입니다 — 밝기가 낮은(그림자) 픽셀일수록 그림자용 색을 더 많이 섞고, 밝기가 높은(하이라이트) 픽셀일수록 하이라이트용 색을 더 많이 섞습니다. 이 데모는 각 픽셀의 휘도로 그림자 가중치와 하이라이트 가중치를 계산해 두 색을 각각 그 비율만큼 더합니다. 그림자에 청록, 하이라이트에 주황을 넣는 "티일 앤 오렌지"가 영화 색보정의 대표적인 조합입니다.

사진 전체를 한 색으로 물들이는 컬러 그레이딩보다 훨씬 미묘합니다 — 그림자와 하이라이트가 서로 다른, 때로는 보색에 가까운 색을 받기 때문에 자연스러운 톤 대비와 "필름 같은" 느낌을 만듭니다. 흑백 사진에 아주 옅게 걸어 세피아·시네마틱 톤을 주는 데도 씁니다.

두 색의 채도를 너무 올리면 인물 사진에서 피부 그림자가 초록빛이나 보랏빛으로 보이는 부자연스러운 결과가 나옵니다. 그림자·하이라이트 경계(중간톤)에서 두 색이 급격히 전환되면 띠(밴딩)처럼 보이므로, 전환 구간을 완만하게 만드는 것이 중요합니다.

언제 쓰나

필름 같은 톤 대비나 시네마틱한 무드를 원할 때 씁니다. 두 색의 채도를 과하게 올리면 피부 그림자가 부자연스러운 색으로 보입니다.