用Javascript画一个阳光彩虹小三角形

255 阅读4分钟

hello大家好,最近看到我两三年前学习图形学的代码,感觉忘了不少,现在这里写文章回顾一下,这是之前写的成果 github.com/925558718/C…

更多文章

68747470733a2f2f67697465652e636f6d2f67616761796f752f63616e7661732d72656e6465722f7261772f6d61737465722f73686f772f636f772e706e67.png

screenshots_output.webp

本期先回顾一下如何画一个三角形

准备渲染环境canvas

const canvas = document.querySelector<HTMLCanvasElement>("#canvas")!;
const ctx = canvas.getContext("2d")!;

const buffer = new Uint8ClampedArray(500 * 500 * 4);

ctx.putImageData(new ImageData(buffer, 500, 500), 0, 0);

弄一个buffer,我们修改这个buffer,然后渲染到canvas里面

三角形数据定义

可以用一个数组保存三个顶点,如[p1,p2,p3]。

我们现在的canvas是500x500的,所以我们定义一个三角形为[[0,0],[250,500],[500,500]],这样它的包围盒就是整个canvas了

canvas的坐标系原点在左上角,所以我们的目标是渲染出一个尖头向左的三角形

画三角形的两种方式

扫描线画法

这个办法就是由上到下的竖直扫描,找出某一个水平线和三角形的两个交点,然后在遍历两个交点中的像素点并着色。 先排序一下顶点,确保高的顶点在前边

  // 按 y 坐标排序顶点
  const sortedVertices = [...points].sort((a, b) => a[1] - b[1]);
  const [p0, p1, p2] = sortedVertices;

为了方便操作,我们对上下两个区域分别进行操作

屏幕截图 2025-06-28 153910.png

开始遍历

  // 求交点,其实也是算插值,已值中间点的y,求x分量
  function interpolate(y: number, y0: number, x0: number, y1: number, x1: number): number {
    if (y1 === y0) return x0;
    return x0 + (x1 - x0) * (y - y0) / (y1 - y0);
  }
  function fillUpperTriangle() {
    if (p1[1] === p0[1]) return; // 避免除零
    
    for (let y = p0[1]; y <= p1[1]; y++) {
      // 计算左右边界的 x 坐标
      const x1 = interpolate(y, p0[1], p0[0], p1[1], p1[0]); // p0 到 p1 的边
      const x2 = interpolate(y, p0[1], p0[0], p2[1], p2[0]); // p0 到 p2 的边
      
      // 确保 xLeft < xRight
      const xLeft = Math.min(x1, x2);
      const xRight = Math.max(x1, x2);
      
      // 填充该行
      for (let x = Math.ceil(xLeft); x <= Math.floor(xRight); x++) {
        setPixel(x, y, color[0], color[1], color[2], color[3]);
      }
    }
  }
    // 设置像素颜色的辅助函数
  function setPixel(x: number, y: number, r: number, g: number, b: number, a: number) {
    if (x < 0 || x >= 500 || y < 0 || y >= 500) return;
    
    const index = (Math.floor(y) * 500 + Math.floor(x)) * 4;
    buffer[index] = r;     // Red
    buffer[index + 1] = g; // Green
    buffer[index + 2] = b; // Blue
    buffer[index + 3] = a; // Alpha
  }

下半部分也是类似的操作,遍历下半区域的就好

遍历+重心坐标

扫描线算法很快,但是能做的事有限,如果我想渲染一个彩色三角形阁下应该如何应对

渲染彩色三角形,可以规定三个顶点的颜色,可是中间的点的颜色如何定义,这时候就需要计算重心坐标

什么是重心坐标?

简单来说就是三角形中任一点 P=αP1​+βP2​+γP3​ ,并且α+β+γ=1,α,β,γ都大于等于0,这样三个系数也能和颜色相乘,并算出该点的颜色插值。

如何计算重心坐标

面积法

点p连接其他三点构成三个区域,三个区域的面积在总面积的占比就是对应的系数 ,比如说点p3,他的系数就是Area(p,p2,p1)/Area(p1,p2,p3),正好是另外两点和点p区域的占总面积的百分比。

  function getBarycentricCoordinates(px: number, py: number): [number, number, number] {
    // 使用面积法计算重心坐标
    const denominator = (p1[1] - p2[1]) * (p0[0] - p2[0]) + (p2[0] - p1[0]) * (p0[1] - p2[1]);
    
    if (Math.abs(denominator) < 1e-10) {
      return [0, 0, 0]; // 退化三角形
    }
    
    // 重心坐标 u, v, w (对应顶点 p0, p1, p2)
    const u = ((p1[1] - p2[1]) * (px - p2[0]) + (p2[0] - p1[0]) * (py - p2[1])) / denominator;
    const v = ((p2[1] - p0[1]) * (px - p2[0]) + (p0[0] - p2[0]) * (py - p2[1])) / denominator;
    const w = 1 - u - v;
    
    return [u, v, w];
  }

然后开始遍历整个包围盒

  // 遍历包围盒内的所有像素
  for (let y = minY; y <= maxY; y++) {
    for (let x = minX; x <= maxX; x++) {
      // 计算当前像素的重心坐标
      const [u, v, w] = getBarycentricCoordinates(x + 0.5, y + 0.5); // +0.5 用于像素中心采样
      
      // 如果点在三角形内部,则填充
      if (isInsideTriangle(u, v, w)) {
        // 使用重心坐标插值计算颜色
        const [r, g, b, a] = interpolateColor(u, v, w);
        setPixel(x, y, r, g, b, a);
      }
    }
  }

但是这样遍历包围盒每个点再计算重心比较费事,可以加一些优化判断点是否在三角形内再算重心坐标,这里不在赘述,直接看结果把。

屏幕截图 2025-06-28 161728.png