程云来 / 杭州
← 工程实践·模型渲染

一个三维模型,是怎样出现在浏览器里的?

拿到一份三维模型文件之后,加载器读到了什么?从文件里的数据到屏幕上可以旋转的物体,中间还需要做哪些事?


拿到模型文件,为什么还不能直接画出来?

假设我们要把一个带圆角的零件放进网页。设计师给来一个 GLB,加载后可以旋转;工程师给来一个 STEP,把它交给同一个加载器却行不通。两份文件都被称作“三维模型”,差别究竟在哪里?

文件扩展名能帮我们找到对应的工具,却还不能解释工具为什么不同。先把任务缩小一点:如果不用现成模型,只在网页里画出一小块平面,需要提供哪些数据?弄清这个最小输入,才有办法判断一份模型文件已经准备了什么、还缺哪一步。

我们用 Three.js[1]来做这个实验。它负责组织场景并调用浏览器的绘图能力。先从四个点开始,看它们怎样连成一块平面,再回头检查模型文件。

一块网格,首先是“点在哪里、怎样连接”

想象零件上的一小块平面。我们给四个角编号,用三个数字记录每个角在三维空间的位置。只有坐标还画不出表面:同样四个点,可以画成两条线,也可以连成不同的三角形。所以还要告诉渲染器,哪些点组成一片面。

typescript
const positions = new Float32Array([
  0, 0, 0,   // 顶点 0
  1, 0, 0,   // 顶点 1
  1, 1, 0,   // 顶点 2
  0, 1, 0,   // 顶点 3
]);
const indices = [0, 1, 2, 0, 2, 3];
// 两个三角形共享顶点 0、2,拼成一块方形平面。

positions每三个数是一条位置记录;indices每三个编号定义一个三角形。这就是后面 STEP 转换结果中位置数组与索引数组的含义。示例里的坐标只用于解释数据组织方式,实际零件的坐标会由几何内核产生。

在 Three.js 中,这些数组放进 BufferGeometry。可以先把它理解为“一组按顶点编号对齐的数据”:第 i 个位置、第 i 个法线、第 i 个纹理坐标共同描述同一条顶点记录。[2]索引指向的是这条完整记录,不只是一个空间坐标。

这个细节能解释一个常见疑问:为什么一个方盒明明只有八个角,导入后却可能有二十四条顶点记录?同一个角属于三个不同朝向的面;为了让三个面保留各自的法线或纹理接缝,同一位置可以出现多次。看到顶点数增加,不能立即判定模型里有无用的重复点。

位置和连接关系决定了表面的形状,但仍然解释不了另一件事:既然三角形都是平的,为什么模型常常看起来很光滑?这需要把“形状”和“表面怎样反光”分开。

平的三角形,为什么能看起来很光滑?

一片三角形的几何平面只有一个朝向,但用于着色的朝向可以在顶点之间平滑变化。描述朝向的向量叫作法线(normal)。绘制三角形内部时,着色程序可以根据顶点法线得到连续变化的方向,再据此计算明暗。于是,网格轮廓仍由直线段组成,内部的光照却可以连续过渡。

法线改变的是光照计算,不会自动把一条直边变成圆弧。辨别这件事最有效的方式,是把模型放大到轮廓处,再切到线框:表面明暗很柔和,轮廓仍可能有棱角。“看起来光滑”只能说明显示效果,不能证明几何近似已经足够精确。

下面用 Microsoft 的 BoomBox 做一次外观对照。它是公开的 glTF 资产,采用 CC0 许可。GLB 是 glTF 的二进制容器;这份文件把场景、几何和纹理装在一起,加载器可以直接还原出显示所需的对象。[3]

  1. 先看原始材质。旋转模型,注意外壳、扬声器表面和显示屏的差别。
  2. 切到纯色。观察哪些细节消失,哪些仍然改变物体的轮廓。
  3. 再切线框和法线。线框显示三角形连接,法线模式用颜色显示着色方向。两种模式提供的是不同证据。
01 / GLB → SCENEBoomBox
BoomBox 模型预览加载公开模型 · 约 10.6 MB

材质模式切到纯色后,不能把所有变化都归因于“删掉了一张图片”。本例的纯色模式同时换用了统一材质,去掉源贴图,包括法线贴图。这个实验能说明细节依赖外观数据;要进一步确认是哪一张贴图产生了它,还需要逐项检查源材质。

把常见外观数据分成三件事,会更容易定位问题:

  1. 纹理坐标(UV)决定到图片的哪里取样。它把网格上的位置与二维图片对应起来。接缝错位时,应检查这种对应关系。
  2. 材质参数决定表面如何响应光照。例如金属度和粗糙度会改变反射表现;同一个网格可以表现为塑料或金属。
  3. 法线贴图改变局部着色方向。它能补充细小凹凸的光照效果,但普通法线贴图不会增加三角形,也不会改变剪影。

有了这些数据,渲染器还要完成从三维到二维的步骤:把顶点从模型局部坐标变换到相机视角,再投影到屏幕;对三角形覆盖的片段计算颜色,结合深度等测试决定最终图像。文件被解析成对象,与对象被画成像素,是两个不同阶段。

这也解释了为什么拖动模型时不需要重新加载 GLB。本页的轨道控制器改变相机,渲染器拿同一份几何和材质再画一次。GLB 已经替我们准备好了这些数组;接下来的 STEP,则需要先回答“这些数组从哪里来”。

STEP 描述的圆角,还不是 GPU 要画的三角形

如果只想把一个圆柱画出来,可以沿圆周取一些点,再把点连成三角形。但如果还要改变半径、求两块实体的交线,或者判断一个点位于实体内外,只有一堆小平面就不够方便了。工业 CAD 往往保留曲面定义及其边界关系,让这些几何运算有更合适的输入。

这就是边界表示(Boundary Representation,B-rep)出现的位置。可以从零件的一个面逐层理解:

  1. 几何给出曲线或曲面。例如平面、圆柱面,或由控制数据定义的样条曲面。
  2. 边界规定使用曲面的哪一部分。一个平面本身无限延伸;围成的边界把实际使用的面裁出来,孔洞也需要内边界。
  3. 拓扑记录面、边和顶点的连接关系。哪些面共用一条边、面怎样围成壳,以及壳是否封闭,不能仅凭屏幕上的贴合外观判断。

用一个具体文件看看这种描述。这里选用 occt-import-js 公开测试目录里的rounded-cube.step,文件头记录它由 FreeCAD 导出。它是一个带圆角的小方块,形状简单,适合对照平面与弯曲处的处理差异。模型来源与许可随原文件一并保留。

rounded-cube 测试件的网格预览,可观察平面与圆角处的连接
rounded-cube 测试件。本页查看器生成的预览;下面的实验可以旋转它并观察线框。

用文本编辑器打开这个 STEP 文件,可以找到 ADVANCED_FACE这样的实体记录。它描述的是前面提到的、有边界的面,与索引数组里由三个顶点组成的三角形不同。一个弯曲的 B-rep 面,需要用多个三角形才能近似画出来。查看器因此分别统计“源 B-rep 面”和“三角形”:前者来自文件的边界表示,后者来自转换后的网格。

OpenCASCADE[4]几何内核能够解释这些实体并生成显示网格。它需要读取曲面和拓扑、处理边界,再按给定约束离散曲面。我们使用的 occt-import-js[5]把这部分能力编译成 WebAssembly,浏览器因此能运行同一类几何处理,而不必把 STEP 上传给后端。

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    A[STEP 字节] --> B[曲面与拓扑]
    B --> C[按参数三角化]
    C --> D[位置、法线、索引]
    D --> E[Three.js 网格]
    E --> F[屏幕图像]

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

STEP 到图像的数据流。几何内核负责前三个处理步骤,Three.js 接收生成的网格。

“DCC 追求视觉、CAD 追求精确”可以帮助理解两类工作的侧重,但不能当作文件格式的完整定义。DCC 也可能使用曲面,STEP 也可能包含离散表示;STEP 中还可以有样条曲面,而不只有解析曲面。本例特意选取 B-rep STEP,讨论的是这条确定的输入路径。

生成网格的过程还缺一个选择:圆角上究竟取多少点?如果没有对近似程度的约束,一份源几何可以生成许多不同的显示网格。

精度参数控制的是近似,不是给模型“加画质”

先把三维圆角切出一个二维截面。用一条弦代替一段圆弧,中间会有间隙;把圆弧切成更短的几段,每段弦通常更贴近曲线。三角化也面临类似问题,只不过实际处理还要同时满足曲面和边界的约束。

用折线近似同一段圆弧左侧用两条弦,右侧用八条弦。蓝色圆弧相同,更多线段减小了弦与弧之间的间隙。2 条线段8 条线段
同一段圆弧,蓝线是曲线,黑线是近似它的弦。这里用二维截面解释偏差,不是 OpenCASCADE 三角化算法的复刻。

线性偏差约束与距离有关的近似程度;角度偏差则从方向变化的角度约束离散。它们并不是两个互不相关的“质量分数”。一项设置已经让采样足够密时,稍微收紧另一项,结果可能暂时不变。真实内核还要考虑源几何的公差和具体算法,所以设置值不能直接写成实测最大误差。

这个实验把输出单位设为毫米,使用绝对线性偏差,角度偏差固定为 0.5 rad。这样,选择 0.01 就明确表示 0.01 mm 的偏差设置,而不是包围盒大小的某个比例。现在可以亲自观察约束如何影响结果:

  1. 启动实验,选择 1 mm,重新生成并切到线框,记录三角形数。
  2. 改成 0.1 mm 再生成。数量仍可能相同,不要据此认定按钮没生效。
  3. 继续改成 0.001 mm。保持相机不动,比较圆角附近新增的边与轮廓变化。
02 / STEP → MESHRounded cube
圆角 STEP 测试件预览按需加载几何内核 · 本地浏览器处理

在当前固定源文件和 occt-import-js 0.0.23 下,四档参数得到 40、40、84、236 个三角形,源 B-rep 面始终是 7 个,包围盒始终是 10 × 10 × 10 mm。线框还能说明新增三角形放在哪里:平面并不需要像弯曲区域一样密集。网格数量应该结合它的分布来读。

这组记录证明“改变参数可以改变离散表示”,但还没有证明某一档适合你的产品。一个只占几十像素的小零件,增加三角形可能几乎看不出区别;同一个圆角占满屏幕时,分段轮廓就明显了。针对网页展示,我会先确定最接近的允许观察距离,再检查该视角下的轮廓,而不是只追求更小的参数值。

时间列也需要同样谨慎。它只测 ReadStepFile,包含 STEP 解析与三角化,不包含网络下载、内核初始化、线程传输或 GPU 绘制。首次调用可能受到预热影响,某次细网格反而更快并不矛盾。要判断生产性能,需要固定设备和条件,重复测量,并分别记录加载、处理与绘制;一次毫秒数不能承担全部结论。

现在我们知道了输入文件、近似参数和输出网格各自表示什么。接下来把它们接成代码,避免把“内核输出了网格”当作一句省略掉全部实现的解释。

沿着一次调用,把 STEP 接到 Three.js

下面沿用同一个 rounded-cube。先约定一个转换结果的最小形状:它有位置数组、可选的法线数组和索引数组。文章用 TypeScript 说明契约;仓库中的可运行脚本使用 JavaScript,便于直接由 Node 执行,两者对应相同的数据链路。

typescript
type Surface = {
  attributes: {
    position: { array: number[] };
    normal?: { array: number[] };
  };
  index: { array: number[] };
};

// 输入:内核返回的一个表面网格,坐标单位为 mm。
// 输出:可加入 Three.js 场景的 Mesh,坐标单位为 m。
function buildMesh(surface: Surface): THREE.Mesh {
  const geometry = new THREE.BufferGeometry();
  const positions = Float32Array.from(
    surface.attributes.position.array, x => x * 0.001,
  );
  geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
  geometry.setIndex(surface.index.array);
  if (surface.attributes.normal) {
    geometry.setAttribute("normal", new THREE.Float32BufferAttribute(
      surface.attributes.normal.array, 3,
    ));
  } else {
    geometry.computeVertexNormals();
  }
  return new THREE.Mesh(geometry, new THREE.MeshStandardMaterial());
}

buildMesh是项目写的适配函数,不是 OpenCASCADE 自带的 Three.js 接口。它读取上一阶段输出的数组,建立前面解释过的 BufferGeometry,再附上材质。BufferAttribute(positions, 3)中的 3 表示每条位置有 x、y、z 三个分量;没有这层分组含义,一串数字并不能自动成为顶点。

乘以 0.001 则是明确的单位转换。本页约定 Three.js 场景使用米,导入器输出毫米,所以 10 mm 变为 0.01 m。Three.js 本身不会替你的任意坐标猜单位;把这个约定放在接入边界,比在每个相机和导出函数里各自修正更容易核对。

调用者负责先读取原 STEP 字节、初始化内核,再把转换结果交给 buildMesh。以下代码接续上面的函数;THREE来自 threeocctFactory来自 occt-import-js

typescript
const kernel = await occtFactory();
const result = kernel.ReadStepFile(stepBytes, {
  linearUnit: "millimeter",
  linearDeflectionType: "absolute_value",
  linearDeflection: 0.001,
  angularDeflection: 0.5,
});
if (!result.success || !result.meshes.length) {
  throw new Error("STEP import failed");
}
const mesh = buildMesh(result.meshes[0]);
// 本例是一个 body;装配模型需另行保留层级与变换。
scene.add(mesh);
renderer.render(scene, camera);

stepBytes是源文件的 Uint8Array,而不是文件名。scene保存要画的对象,camera规定观察位置,renderer连接画布并执行绘制。这三个显示对象由本页查看器创建。result.meshes[0]只因为当前源文件是单体测试件才足够,不能拿这行代码当作通用装配导入方案。

可以在本站仓库根目录直接运行下面的完整示例。它实际读取同一个 STEP、调用内核、建立 Three.js Mesh 并检查包围盒;Node 版本不创建 WebGL 画布,最后的绘制由上方浏览器实验验证。

bash
npm install
node public/data/model-rendering/render-step.mjs
text
{ deflection: 0.1, triangles: 40, dimensionsMm: [10, 10, 10] }
{ deflection: 0.001, triangles: 236, dimensionsMm: [10, 10, 10] }

已在 Three.js 0.185.1、occt-import-js 0.0.23 下验证(2026-09-08)。完整可运行脚本保留导入、文件读取、调用和资源释放。

为什么浏览器版本要多一个 Worker?

上面的 ReadStepFile是同步处理。代码写在 async函数里,并不意味着 CPU 工作自动搬到了后台:若直接在主线程执行较重的几何计算,按钮、滚动和绘制仍可能一起等待。

Web Worker[6]提供独立的脚本执行环境。本页让它持有几何内核和缓存的源字节;每次收到参数,重新调用内核。主线程只接收结果、替换网格和更新文字。Worker 传回的是可转移的数组缓冲,不是 Three.js 场景对象:场景仍由主线程创建和管理。

正在绘制图表…
查看 Mermaid 源码
sequenceDiagram
    actor R as 读者
    participant U as 页面主线程
    participant W as STEP Worker
    R->>U: 选择 0.001 mm,应用
    U->>W: 任务 ID、偏差参数
    W->>W: ReadStepFile 解析并三角化
    W-->>U: 同一 ID、数组缓冲、耗时
    U->>U: 核对 ID,buildMesh,替换旧网格
    U-->>R: 相机保持,显示 236 个三角形

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

这一步把昂贵计算与交互分开,同时引入了新的责任:结果回来时,读者可能已经改了参数、取消了任务,甚至离开页面。文件能被成功解析,只解决了处理链路的一半;另一半是确认当前显示的结果确实属于当前操作。

能画出来之后,怎样知道没有画错结果?

症状一:模型大小正常,导出后的尺寸却变了

查看器通常会自动居中和缩放,让不同尺寸的模型都能放进同一块画布。假设把一个 0.01 m 的零件直接放大 260 倍,再导出带这个缩放的对象,视觉归一化就可能跟着资产一起离开页面。屏幕上看不出异常,下游读到的尺寸却已经改变。

本页把两种变换放在不同层:源网格在接入时从 mm 转成 m;用于适应画布的居中和缩放只作用于显示父组。导出时取源组,排除显示父组。验证也不靠截图:实际重新解析导出的 GLB,检查三角形数和米制包围盒。原来的 10 mm 边长应仍是 0.01 m,而不是“看起来差不多大”。

症状二:选了更细参数,画面却回到了旧结果

考虑一条可能发生的轨迹:任务 A 使用 0.1 mm,随后任务 B 使用 0.001 mm。如果旧任务完成后仍能无条件替换网格,页面就可能把旧结果标成新参数。单独保存一个“正在加载”的布尔值,无法说明这次完成事件究竟属于哪次请求。

本页为处理任务分配递增 ID。结果返回后,只有 ID 与当前任务一致才允许替换网格;重新开始或取消时还会终止旧 Worker。检查这类问题时,应同时核对请求参数、任务 ID、已应用参数和显示统计,不能只看加载动画有没有消失。

症状三:内核加载失败,页面只剩一块空白

STEP 查看器至少经历内核加载、源文件读取和网格处理几个阶段。网络里 WASM 请求失败,与源文件解析失败不是同一个原因。只显示“模型错误”,读者无法判断该重试网络,还是检查文件。

本页保留阶段提示和重试入口:初次失败不伪造一个处理结果;已有成功网格时,处理失败或取消会保留它,并保留它对应的已应用参数。可在浏览器网络工具中阻断 /vendor/occt/ 请求后重载实验,确认失败提示出现;解除阻断并重试,应恢复显示。仓库的浏览器验证还会注入 Worker 错误,检查旧网格没有被清空。

这三类问题分别发生在坐标变换、异步状态和资源加载层。把症状放回对应层检查,往往比盲目替换模型或升级渲染库更有效。现在才有足够依据回答最初的交付问题:每次访问都应该从 STEP 开始吗?

源文件留在哪里,浏览器就一定要从哪里开始吗?

如果网页只展示固定零件,读者不会修改三角化参数,那么让每个人重复下载内核、重新解析同一份 STEP,通常没有必要。在这个条件下,我会离线生成合适的 GLB,把转换参数与源文件版本记录下来,浏览器直接加载展示资产。源 STEP 仍然保留,只是不必出现在每一次访问的处理链路里。

如果用户需要导入新文件,或者像本文一样研究精度变化,在线转换就有价值。代价是内核下载、CPU 时间、内存与错误状态管理。是否使用后端,则要进一步看文件规模、设备能力和处理约束;“能在浏览器跑”本身不是把所有 CAD 处理都放在浏览器里的理由。

当前任务我会优先选择必须保留的证据
反复展示固定模型预生成 GLB源文件版本、单位、转换参数
调整精度并观察近似STEP → 内核 → 实时网格本次参数、已应用结果、阶段耗时
修改几何或继续工程处理保留 CAD / STEP 源表示几何与拓扑、必要的工程语义
查询温度、应力等结果网格与原始场数据一起管理数值、单位、节点或单元关联

GLB 统一的是一类展示资产,不是全部工程信息。导出后重新解析,能检查三角形和包围盒是否保持,却不能把三角网格还原成原来的精确曲面,也不能恢复 CAD 软件里的参数化特征历史。类似地,VTK[7]把部分可视化结果导出为 glTF,并不等于原始物理场与体数据也完整转移了;保存一张顶点颜色云图,无法替代保存产生它的数值。

回到开头那两个文件:GLB 已经提供了适合显示的网格与外观数据,先检查加载和渲染;本例的 STEP 则要先确认曲面到网格的转换,再检查显示。遇到圆角不够圆,先区分轮廓近似与着色;遇到尺寸不对,先核对单位与显示变换;遇到参数和画面不一致,检查任务与结果的对应关系。

专业判断最终落在这些可以核对的关系上:我为什么选择这份表示,在哪一步允许近似,用什么证据确认输出,以及哪些信息必须留在源数据里。

注释与复现资料

  1. Three.js:本页使用的三维库,负责场景、相机、网格和 WebGL 绘制。
  2. BufferGeometry:按顶点对齐的属性数组及索引组织;同一位置可以对应不同属性记录。
  3. glTF 2.0 规范:本页使用的场景与网格资产格式,GLB 是它的二进制容器。
  4. OpenCASCADE 网格生成:将几何形状离散成显示网格,本例由它处理 B-rep 三角化。
  5. occt-import-js:浏览器和 Node 可用的 OCCT 导入接口,提供参数、单位和网格结果。
  6. Web Worker:在独立执行环境中处理脚本,通过消息与页面通信;本页在其中运行几何内核。
  7. VTK glTF exporter:支持部分渲染场景导出,不保存全部底层科学数据。

事实与示例核对日期:2026-09-08。模型:Microsoft BoomBox(CC0);kovacsv / occt-import-js rounded-cube(上游 LGPL-2.1 仓库测试资产)。固定来源、许可和文件哈希 · 原 STEP · 原 GLB