You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Canvas绘制三角形出现伪影的原因及解决方案疑问

问题描述

我已经检查Mesh、变换矩阵及所有能想到的内容十多次,伪影总是在time变量达到1800时出现(该变量每次setTimeout递增),我将初始time设为1700以便观察。请运行下方代码片段。

编辑:我找到了解决方案,但不清楚原理。若将fill语句移至绘制三角形后,三角形仍会残留!我仅将分开的rect、fill命令改为fillRect就修复了问题,这是为什么?

代码片段

JavaScript

/* classes */

class Vec3 {
  constructor(x = null, y = null, z = null) {
    this.x = x
    this.y = y
    this.z = z
  }
}

class Triangle {
  constructor(x1 = null, y1 = null, z1 = null, x2 = null, y2 = null, z2 = null, x3 = null, y3 = null, z3 = null) {
    this.v = [
      new Vec3(x1, y1, z1),
      new Vec3(x2, y2, z2),
      new Vec3(x3, y3, z3),
    ]
  }
}

class Mesh {
  constructor(tris = []) {
    this.t = tris.map(t => new Triangle(...t))
  }
}

class Mat4x4 {
  constructor(fn = () => {}) {
    this.m = [
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
      [0, 0, 0, 0],
    ]
    fn(this.m)
  }
}

/* helpers */

function degToRad(deg) {
  return deg * (math.pi / 180)
}

/* engine */

function matMultiplyVec3(mat, vec, vecP) {
  vecP.x = vec.x * mat.m[0][0] + vec.y * mat.m[1][0] + vec.z * mat.m[2][0] + mat.m[3][0]
  vecP.y = vec.x * mat.m[0][1] + vec.y * mat.m[1][1] + vec.z * mat.m[2][1] + mat.m[3][1]
  vecP.z = vec.x * mat.m[0][2] + vec.y * mat.m[1][2] + vec.z * mat.m[2][2] + mat.m[3][2]
  const w = vec.x * mat.m[0][3] + vec.y * mat.m[1][3] + vec.z * mat.m[2][3] + mat.m[3][3]
  if (w !== 0) {
    vecP.x /= w; vecP.y /= w; vecP.z /= w;
  }
  // const m1 = math.matrix(mat.m)
  // const m2 = math.matrix([[vec.x], [vec.y], [vec.z], [1]])
  // const [[xP], [yP], [zP], [w]] = math.multiply(m1, m2).toArray()
  // vecP.x = xP; vecP.y = yP; vecP.z = zP
  // if (w != 0) {
  //   vecP.x /= w; vecP.y /= w; vecP.z /= w
  // }
}

/* canvas */

function setupCanvas(width, height) {
  const c = document.getElementById('canvas')
  c.setAttribute('width', width)
  c.setAttribute('height', height)
  c.style.width = `${width}px`
  c.style.height = `${height}px`
  const ctx = c.getContext('2d')
  return { c, ctx, width, height }
}

function fill(ctx, x, y, width, height, color) {
  ctx.rect(x, y, width, height)
  ctx.fillStyle = color
  ctx.fill()
}

function drawTriangle(ctx, tri, color) {
  ctx.strokeStyle = color
  ctx.lineWidth = 1
  ctx.beginPath()
  ctx.moveTo(tri.v[0].x, tri.v[0].y)
  ctx.lineTo(tri.v[1].x, tri.v[1].y)
  ctx.lineTo(tri.v[2].x, tri.v[2].y)
  ctx.stroke()
}

/* main */

function main() {
  // canvas setup
  const { ctx, width, height } = setupCanvas(600, 337)

  // mesh
  const cubeMesh = new Mesh([
    [-1,-1,1, 1,-1,1, 1,1,1],
    [-1,-1,1,1,-1,1,1,1,1],
    [-1,-1,1,1,1,1,-1,1,1],
    [1,-1,1,1,-1,-1,1,1,-1],
    [1,-1,1,1,1,-1,1,1,1],
    [1,-1,-1,-1,-1,-1,-1,1,-1],
    [1,-1,-1,-1,1,-1,1,1,-1],
    [-1,-1,-1,-1,-1,1,-1,1,1],
    [-1,-1,-1,-1,1,1,-1,1,-1],
    [-1,1,1,1,1,1,1,1,-1],
    [-1,1,1,1,1,-1,-1,1,-1],
    [1,-1,1,-1,-1,-1,1,-1,-1],
    [1,-1,1,-1,-1,1,-1,-1,-1],
  ])

  // projection matrix
  const zNear = 0.1
  const zFar = 1000
  const fov = 90
  const scalingFactor = 1 / math.tan(degToRad(fov) / 2)
  const aspectRatio = height / width

  const matProj = new Mat4x4(m => {
        m[0][0] = aspectRatio * scalingFactor
        m[1][1] = scalingFactor
        m[2][2] = zFar / (zFar - zNear)
        m[3][2] = (-zFar * zNear) / (zFar - zNear)
        m[2][3] = 1
  })

  // game loop
  let time = 1500

  const update = () => {
    // clear screen
    fill(ctx, 0, 0, width, height, 'gray')

    // rotation matrices
    const theta = time * 0.005

    const matRotZ = new Mat4x4(m => {
      m[0][0] = math.cos(theta)
      m[0][1] = math.sin(theta)
      m[1][0] = -math.sin(theta)
      m[1][1] = math.cos(theta)
      m[2][2] = 1
      m[3][3] = 1
    })

    const matRotX = new Mat4x4(m => {
      m[0][0] = 1
      m[1][1] = math.cos(theta / 2)
      m[1][2] = math.sin(theta / 2)
      m[2][1] = -math.sin(theta / 2)
      m[2][2] = math.cos(theta / 2)
      m[3][3] = 1
    })

    // draw mesh
    cubeMesh.t.forEach(tri => {
      // rotate Z
      const triRZ = new Triangle
      matMultiplyVec3(matRotZ, tri.v[0], triRZ.v[0])
      matMultiplyVec3(matRotZ, tri.v[1], triRZ.v[1])
      matMultiplyVec3(matRotZ, tri.v[2], triRZ.v[2])

      // rotate X
      const triRZX = new Triangle
      matMultiplyVec3(matRotX, triRZ.v[0], triRZX.v[0])
      matMultiplyVec3(matRotX, triRZ.v[1], triRZX.v[1])
      matMultiplyVec3(matRotX, triRZ.v[2], triRZX.v[2])

      // offset z a bit
      const triT = triRZX
      triT.v[0].z = triRZX.v[0].z + 3
      triT.v[1].z = triRZX.v[1].z + 3
      triT.v[2].z = triRZX.v[2].z + 3

      // project
      const triP = new Triangle
      matMultiplyVec3(matProj, triT.v[0], triP.v[0])
      matMultiplyVec3(matProj, triT.v[1], triP.v[1])
      matMultiplyVec3(matProj, triT.v[2], triP.v[2])

      // scale into view
      triP.v[0].x += 1; triP.v[0].y += 1
      triP.v[1].x += 1; triP.v[1].y += 1
      triP.v[2].x += 1; triP.v[2].y += 1
      triP.v[0].x = triP.v[0].x / 2 * width
      triP.v[0].y = triP.v[0].y / 2 * height
      triP.v[1].x = triP.v[1].x / 2 * width
      triP.v[1].y = triP.v[1].y / 2 * height
      triP.v[2].x = triP.v[2].x / 2 * width
      triP.v[2].y = triP.v[2].y / 2 * height

      // rasterize
      drawTriangle(ctx, triP, 'white')
    })
  }

  setInterval(() => {
    time += 1
    update()
  }, 10)
}

main()

HTML

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Cube</title>

  <style>
    body {
      margin: 20px;
    }
  </style>
</head>
<body>
  <canvas id="canvas"></canvas>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjs/11.4.0/math.js"></script>
</body>
</html>
问题解答

问题核心是Canvas的路径状态累积:

  1. 你原来的fill函数用ctx.rect()把矩形添加到当前路径,再调用ctx.fill()填充,但没有调用ctx.beginPath()重置路径——这意味着每次循环调用fill时,都会把新矩形追加到之前的路径里,路径会随着循环次数越来越庞大。

  2. 当time到1800时,计算出的旋转角度theta会让三角形变换坐标出现细微数值精度误差,再加上累积了几百次的路径,就会触发渲染异常产生伪影;哪怕把填充放到绘制三角形之后,残留的累积路径也会影响下一次渲染。

  3. 而fillRect()是独立的Canvas绘制API,它不需要依赖当前路径——调用时会自动创建全新的矩形路径,填充后不会保留该路径,自然不会出现路径累积的问题,伪影和残留现象也就解决了。

如果不想改用fillRect,也可以修改原fill函数,在ctx.rect()前加ctx.beginPath(),确保每次填充都基于全新路径:

function fill(ctx, x, y, width, height, color) {
  ctx.beginPath() // 添加此行重置路径
  ctx.rect(x, y, width, height)
  ctx.fillStyle = color
  ctx.fill()
}

内容的提问来源于stack exchange,提问作者Tallboy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.10 06:01:37