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

TypeScript 3D引擎三角形法线计算异常求助

3D引擎三角形法线计算异常问题排查

问题背景

使用TypeScript开发3D引擎时,计算立方体三角形法线的结果不符合预期。已确认顶点旋转、平移操作正确,但绘制出的法线方向混乱(部分法线指向立方体内部或朝向错误)。

顶点与三角形数据

interface Vertex {
  x: number;
  y: number;
  z: number;
}

interface Triangle {
  v1: number;
  v2: number;
  v3: number;
}

let vertices: Vertex[] = [
  { x: 0, y: 0, z: 0 },
  { x: 1, y: 0, z: 0 },
  { x: 1, y: 1, z: 0 },
  { x: 0, y: 1, z: 0 },
  { x: 0, y: 0, z: 1 },
  { x: 1, y: 0, z: 1 },
  { x: 1, y: 1, z: 1 },
  { x: 0, y: 1, z: 1 },
];

const triangles: Triangle[] = [
  { v1: 0, v2: 1, v3: 2 }, { v1: 0, v2: 2, v3: 3 }, // Front face
  { v1: 4, v2: 5, v3: 6 }, { v1: 4, v2: 6, v3: 7 }, // Back face
  { v1: 0, v2: 1, v3: 5 }, { v1: 0, v2: 5, v3: 4 }, // Bottom face
  { v1: 2, v2: 3, v3: 7 }, { v1: 2, v2: 7, v3: 6 }, // Top face
  { v1: 0, v2: 3, v3: 7 }, { v1: 0, v2: 7, v3: 4 }, // Left face
  { v1: 1, v2: 2, v3: 6 }, { v1: 1, v2: 6, v3: 5 }  // Right face
];

核心实现代码

顶点变换与法线计算逻辑

const angle = 1;
const rotationX = rotationMatrixX(angle);
const rotationY = rotationMatrixY(angle); 
vertices = vertices.map((vertex) => {
    let [x, y, z, w] = multiplyMatrixVector(rotationX, [
        vertex.x,
        vertex.y,
        vertex.z,
        1,
    ]);
    return {x, y, z};
})
vertices = vertices.map((vertex) => {
    let [x, y, z, w] = multiplyMatrixVector(rotationY, [
        vertex.x,
        vertex.y,
        vertex.z,
        1,
    ]);
    return {x, y, z};
})
const translatedVertices = transformVertices(vertices, translationMatrix);
const culledTriangles = [];
const normals = [];
for (let i = 0; i < triangles.length; i++) {
    const { v1, v2, v3 } = triangles[i];
    const p1 = translatedVertices[v1];
    const p2 = translatedVertices[v2];
    const p3 = translatedVertices[v3];

    const normal = calculateNormal(p1, p2, p3);

    if (normal.z > 0) {
        culledTriangles.push(triangles[i]);
        normals.push(normal);
    }
}
// Transform and project the rotated vertices
const transformedVertices = transformVertices(translatedVertices, perspectiveProjectionMatrix());
const scaledVertices = scaleAndNormalizeVertices(transformedVertices);

// Draw the rotated cube
drawWireframe(ctx, scaledVertices, culledTriangles, normals);

法线计算函数

function calculateNormal(a: Vertex, b: Vertex, c: Vertex): Vertex {
  const line1: Vertex = {
    x: b.x - a.x,
    y: b.y - a.y,
    z: b.z - a.z,
  };

  const line2: Vertex = {
    x: c.x - a.x,
    y: c.y - a.y,
    z: c.z - a.z,
  };

  const normal: Vertex = {
    x: line1.y * line2.z - line1.z * line2.y,
    y: line1.z * line2.x - line1.x * line2.z,
    z: line1.x * line2.y - line1.y * line2.x,
  };

  const length = Math.sqrt(normal.x ** 2 + normal.y ** 2 + normal.z ** 2);
  normal.x /= length;
  normal.y /= length;
  normal.z /= length;

  return normal;
}

线框绘制函数

/**
 * Draw the wireframe of a 3D object
 * @param ctx - The 2D rendering context of the canvas.
 * @param vertices - The array of vertices of the 3D object.
 * @param triangles - The array of triangles of the 3D object.
 */
export function drawWireframe(ctx: CanvasRenderingContext2D, vertices: Vertex[], triangles: Triangle[], normals: Vertex[]) {
    ctx.fillStyle = "#000000";
    ctx.fillRect(0, 0, config.canvas.width, config.canvas.height);

    ctx.strokeStyle = "#ffffff";

    // Add a scale factor to normals to make them more visible
    const normalScale = 50;

    for (let i = 0; i < triangles.length; i++) {
        const { v1, v2, v3 } = triangles[i];
        const p1 = vertices[v1];
        const p2 = vertices[v2];
        const p3 = vertices[v3];

        // 绘制三角形
        ctx.beginPath();
        ctx.moveTo(p1.x, p1.y);
        ctx.lineTo(p2.x, p2.y);
        ctx.lineTo(p3.x, p3.y);
        ctx.closePath();
        ctx.stroke();

        // 计算三角形中心点
        const centerX = (p1.x + p2.x + p3.x) / 3;
        const centerY = (p1.y + p2.y + p3.y) / 3;

        // 获取对应法线并缩放
        const normal = normals[i];
        const endX = centerX + normal.x * normalScale;
        const endY = centerY + normal.y * normalScale;

        // 绘制法线
        ctx.strokeStyle = "#ff0000";
        ctx.beginPath();
        ctx.moveTo(centerX, centerY);
        ctx.lineTo(endX, endY);
        ctx.stroke();
        ctx.strokeStyle = "#ffffff";
    }
};

问题分析与修复建议

1. 三角形顶点缠绕顺序不一致

法线方向由顶点的顺时针/逆时针顺序(右手定则)决定。当前部分面的顶点顺序混乱:

  • 正面顶点为逆时针排列,法线向外;但背面顶点为顺时针排列,导致法线指向内部。

修复: 统一所有三角形的顶点缠绕顺序(全部使用逆时针),修改背面三角形定义:

// 原背面三角形
// { v1: 4, v2: 5, v3: 6 }, { v1: 4, v2: 6, v3: 7 }, 
// 修改为
{ v1: 4, v2: 6, v3: 5 }, { v1: 4, v2: 7, v3: 6 }, 

同步检查其他面的顶点顺序,确保从外部观察均为逆时针排列。

2. Canvas坐标系Y轴方向差异

3D空间Y轴通常向上,而Canvas的Y轴向下,导致法线绘制时Y方向反转。

修复: 绘制法线时反转Y分量:

// 原代码
// const endY = centerY + normal.y * normalScale;
// 修改为
const endY = centerY - normal.y * normalScale;

3. 背面剔除的空间坐标系不匹配

当前基于世界空间的normal.z > 0判断背面,若相机朝向-Z方向,视图空间的Z轴逻辑会与世界空间冲突。

修复: 将法线转换到视图空间后再做剔除判断:

// 假设viewMatrix为相机视图矩阵
const viewNormal = multiplyMatrixVector(viewMatrix, [normal.x, normal.y, normal.z, 0]);
if (viewNormal.z > 0) {
    culledTriangles.push(triangles[i]);
    normals.push(normal);
}

注意:变换法线时使用向量(w=0),避免平移操作影响方向。


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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.23 19:09:58