Introduction

Every frame your GPU renders is built out of triangles. Every triangle needs to be filled: given a pixel somewhere inside the triangle, what color should it have? The answer relies on a deceptively simple idea called barycentric coordinates.

The concept was introduced by the German mathematician August Ferdinand Möbius in 1827. His insight: instead of giving a point a position in x,yx, y space, express it as a weighted mixture of the triangle's three corners. If the triangle has vertices AA, BB, and CC, then every interior point PP can be written as

P=λ1A+λ2B+λ3CP = \lambda_1 A + \lambda_2 B + \lambda_3 C

where λ1+λ2+λ3=1\lambda_1 + \lambda_2 + \lambda_3 = 1 and each λi≄0\lambda_i \geq 0. The three numbers (λ1,λ2,λ3)(\lambda_1, \lambda_2, \lambda_3) are the barycentric coordinates of PP.

What makes this powerful is that the same weights apply to any attribute you attach to the vertices — color, depth, texture coordinates, surface normals. Interpolate the weights, and every attribute comes along for the ride.

Try It

The triangle below has a red vertex, a green vertex, and a blue vertex. Click or drag anywhere inside to place a point. The three barycentric weights λ1\lambda_1, λ2\lambda_2, λ3\lambda_3 are computed and used to mix the vertex colors — giving the point exactly the blend it deserves.

<!-- Barycentric coordinate color-blend demo -->
<div class="hint" id="hint">Click or drag inside the triangle. The three barycentric weights (λ₁, λ₂, λ₃) determine the point's color as a blend of vertex A (red), B (green), and C (blue).</div>
<div class="canvas-wrap">
  <canvas id="cvs" width="360" height="300"></canvas>
</div>
<div class="coords" id="coords">Click inside the triangle to place a point.</div>
<div class="weights" id="weights"></div>
<button id="reset" type="button" class="ghost">Reset</button>
/* Layout and visual styles */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.canvas-wrap { display: flex; justify-content: center; }
canvas { border-radius: 10px; cursor: crosshair; touch-action: none; display: block; max-width: 100%; }
.coords { font-size: .9rem; color: #555; margin: .5rem 0 .2rem; min-height: 1.3em; }
.weights { display: flex; gap: .6rem; flex-wrap: wrap; margin-bottom: .5rem; min-height: 1.6em; }
.w-chip { font-size: .85rem; font-weight: 600; padding: .2rem .55rem; border-radius: 999px; color: #fff; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .8rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
// Barycentric coordinates: any interior point P = λ1·A + λ2·B + λ3·C
// where λ1 + λ2 + λ3 = 1 and all λi >= 0.
// The weights are computed via signed triangle areas (2D cross products).
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

const cvs = document.getElementById('cvs');
const ctx = cvs.getContext('2d');
const W = cvs.width, H = cvs.height;

// Triangle vertices with RGB colors (red, green, blue corners)
const V = [
  { x: W * 0.5,  y: H * 0.06, r: 230, g: 57,  b: 70  }, // red vertex (top)
  { x: W * 0.05, y: H * 0.93, r: 29,  g: 185, b: 84  }, // green vertex (bottom-left)
  { x: W * 0.95, y: H * 0.93, r: 69,  g: 123, b: 210 }  // blue vertex (bottom-right)
];

// 2D cross product: area of parallelogram spanned by (a) and (b)
function cross2d(ax, ay, bx, by) { return ax * by - ay * bx; }

// Compute barycentric coordinates for point (px, py)
function bary(px, py) {
  const [A, B, C] = V;
  const denom = cross2d(B.x - A.x, B.y - A.y, C.x - A.x, C.y - A.y);
  const l1 = cross2d(B.x - px, B.y - py, C.x - px, C.y - py) / denom;
  const l2 = cross2d(C.x - px, C.y - py, A.x - px, A.y - py) / denom;
  const l3 = 1 - l1 - l2;
  return [l1, l2, l3];
}

// Point is inside the triangle iff all weights are non-negative
function inside(l1, l2, l3) { return l1 >= 0 && l2 >= 0 && l3 >= 0; }

// Fill the triangle pixel-by-pixel using barycentric interpolation
function drawTriangle() {
  // For each pixel, compute barycentric coords and blend vertex colors
  const imgData = ctx.createImageData(W, H);
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      const [l1, l2, l3] = bary(x + 0.5, y + 0.5);
      if (!inside(l1, l2, l3)) continue;
      const i = (y * W + x) * 4;
      imgData.data[i]     = Math.round(l1 * V[0].r + l2 * V[1].r + l3 * V[2].r);
      imgData.data[i + 1] = Math.round(l1 * V[0].g + l2 * V[1].g + l3 * V[2].g);
      imgData.data[i + 2] = Math.round(l1 * V[0].b + l2 * V[1].b + l3 * V[2].b);
      imgData.data[i + 3] = 255;
    }
  }
  ctx.putImageData(imgData, 0, 0);

  // Draw labeled vertex dots on top
  V.forEach((v, i) => {
    ctx.beginPath();
    ctx.arc(v.x, v.y, 8, 0, Math.PI * 2);
    ctx.fillStyle = `rgb(${v.r},${v.g},${v.b})`;
    ctx.fill();
    ctx.strokeStyle = '#fff';
    ctx.lineWidth = 2;
    ctx.stroke();
    const label = ['A', 'B', 'C'][i];
    ctx.fillStyle = '#fff';
    ctx.font = 'bold 12px system-ui, sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(label, v.x, v.y);
  });
}

let dot = null; // last clicked/dragged point

// Draw the user's chosen point
function drawDot() {
  if (!dot) return;
  ctx.beginPath();
  ctx.arc(dot.x, dot.y, 6, 0, Math.PI * 2);
  ctx.fillStyle = dot.color;
  ctx.fill();
  ctx.strokeStyle = '#fff';
  ctx.lineWidth = 2;
  ctx.stroke();
}

function render() {
  ctx.clearRect(0, 0, W, H);
  drawTriangle();
  drawDot();
}

// Compute and display weights for the chosen point
function update(px, py) {
  const [l1, l2, l3] = bary(px, py);
  if (!inside(l1, l2, l3)) return;

  const r = Math.round(l1 * V[0].r + l2 * V[1].r + l3 * V[2].r);
  const g = Math.round(l1 * V[0].g + l2 * V[1].g + l3 * V[2].g);
  const b = Math.round(l1 * V[0].b + l2 * V[1].b + l3 * V[2].b);
  const color = `rgb(${r},${g},${b})`;

  dot = { x: px, y: py, color };
  render();

  document.getElementById('coords').textContent =
    fmt('Point at (%x%, %y%)', { x: Math.round(px), y: Math.round(py) });

  const labels = ['λ₁ (A)', 'λ₂ (B)', 'λ₃ (C)'];
  const vals = [l1, l2, l3];
  const chips = ['#e63946', '#1db954', '#457bd9'];
  document.getElementById('weights').innerHTML = vals.map((v, i) =>
    `<span class="w-chip" style="background:${chips[i]}">${labels[i]} = ${v.toFixed(3)}</span>`
  ).join('');
}

// Mouse and touch event handlers
function getPos(e) {
  const rect = cvs.getBoundingClientRect();
  const scaleX = W / rect.width;
  const scaleY = H / rect.height;
  const src = e.touches ? e.touches[0] : e;
  return { x: (src.clientX - rect.left) * scaleX, y: (src.clientY - rect.top) * scaleY };
}

let dragging = false;
cvs.addEventListener('mousedown', e => { dragging = true; const p = getPos(e); update(p.x, p.y); });
cvs.addEventListener('mousemove', e => { if (dragging) { const p = getPos(e); update(p.x, p.y); } });
cvs.addEventListener('mouseup',   () => { dragging = false; });
cvs.addEventListener('touchstart', e => { e.preventDefault(); const p = getPos(e); update(p.x, p.y); }, { passive: false });
cvs.addEventListener('touchmove',  e => { e.preventDefault(); const p = getPos(e); update(p.x, p.y); }, { passive: false });

document.getElementById('reset').addEventListener('click', () => {
  dot = null;
  render();
  document.getElementById('coords').textContent = 'Click inside the triangle to place a point.';
  document.getElementById('weights').innerHTML = '';
});

render();
Result
Console

Notice that points near a corner look almost like that corner's color. Points near the center mix all three equally. The weights always sum to 1, so the blend is always a proper convex combination — it can never produce a color outside the triangle's palette.

The Math

How do you actually compute (λ1,λ2,λ3)(\lambda_1, \lambda_2, \lambda_3) for a given point PP?

The elegant answer uses signed triangle areas. Write SABCS_{ABC} for the (signed) area of the full triangle and define

λ1=SPBCSABC,λ2=SAPCSABC,λ3=SABPSABC\lambda_1 = \frac{S_{PBC}}{S_{ABC}}, \quad \lambda_2 = \frac{S_{APC}}{S_{ABC}}, \quad \lambda_3 = \frac{S_{ABP}}{S_{ABC}}

Each weight is the fraction of the total area occupied by the sub-triangle opposite to its vertex. Because the three sub-triangles tile the whole triangle, λ1+λ2+λ3=1\lambda_1 + \lambda_2 + \lambda_3 = 1 automatically.

In practice, each signed area is a 2D cross product (one subtraction and two multiplications). GPUs evaluate this for every pixel in the triangle's bounding box, discard pixels where any λi<0\lambda_i < 0 (outside the triangle), and pass the three weights to the fragment shader for every pixel that survives.

  • Cost per pixel: three cross products — constant time, fully parallelizable.
  • Perspective-correct interpolation: in 3D, the weights must be divided by the vertex depth ww before interpolating and re-multiplied after, a tweak handled automatically by the GPU pipeline.
  • Point-in-triangle test: a free by-product — PP is inside iff all three λi≄0\lambda_i \geq 0.

The barycentric machinery is so cheap and so general that it is the foundation of virtually every triangle rasterizer ever built, from classic software renderers to modern real-time GPUs.

Where It Matters

The same three-weight blend turns up in a surprising range of fields:

  • Real-time 3D graphics: every triangle rasterized by a GPU uses barycentric coordinates to interpolate vertex colors, normals, and UV texture coordinates across its surface. Without them, triangles would be flat, uniform patches — no shading gradients, no textures.
  • Texture mapping: UV coordinates are stored at vertices and blended barycentrically. Moving across a triangle smoothly scans the corresponding region of the texture.
  • Depth buffering: the zz-value of each pixel is interpolated barycentrically to decide which triangle is in front — the basis of the depth test.
  • Finite element analysis: in engineering simulations (stress, heat, fluid flow), the mesh is built from triangles or tetrahedra. A field value (temperature, displacement) is stored at nodes and interpolated barycentrically inside each element.
  • Animation and skinning: convex hull geometry is divided into triangles; vertex weights in skeletal animation are a direct analogue of barycentric blending.
  • Geographic information systems: elevation and other scalar fields are stored at sample points and interpolated across Delaunay triangulation cells using barycentric weights.

Wherever a smooth value must be spread across a triangulated surface, barycentric coordinates are the natural tool.

Conclusion

Barycentric coordinates are one of those ideas that, once you see them, you spot everywhere. Möbius's 1827 insight — express a point as a convex blend of the triangle's corners — turned into the universal language of triangle interpolation. Every vertex attribute (color, depth, texture, normal) rides those three weights across the triangle's surface.

The next time you look at a smooth gradient on a 3D model, a textured polygon in a game, or a temperature map in an engineering simulation, you are looking at barycentric coordinates in action. Three numbers. One triangle. Infinite surfaces.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/barycentric-coordinates/Content licensed under CC BY-NC 4.0.