简介:这是一款基于 JavaScript 的轻量级桌面 3D OBJ 查看器,面向需要快速预览 OBJ 模型的前端开发者和 3D 建模爱好者,可以解决在浏览器中直接查看带材质与纹理模型的需求,也适用于教学演示与模型快速检查。压缩包内共有 7 个文件,整体大小仅 52KB,包含页面入口的超文本文件、两个用于渲染和交互的 JavaScript 脚本、一个 OBJ 示例模型及对应的材质文件,另附许可证与说明文档,目录结构简洁清晰。运行成本很低,打开页面即可在浏览器中使用,将任意 OBJ 文件连同材质与纹理放入指定目录后,便能在界面中选择并切换查看,全程无需安装额外依赖,交互反馈直观,对刚接触三维模型处理的用户尤其友好。配套的茶壶模型可用来验证加载效果,目前已有 1575 人学习使用,帮助不少开发者快速理解了 OBJ 格式的文件组织方式以及 jsc3d 库的渲染调用流程。如果正在寻找一个可离线运行的精简示例,或想基于这套代码扩展自己的模型预览工具,这份资源会是很好的起步参考。
1. 一个能在浏览器里跑起来的简易 OBJ 查看器:从“看静态图”到“拖拽看模型”
做前端三年多,我越来越觉得 3D 这块是绕不开的坎。不管是做产品展示页、WebGL 数据可视化,还是给设计师搭模型预览工具,最基础也最频繁的一个需求就一个字:看。怎么看?用 Three.js 写个带轨道控制的查看器不难,难的是从零到能用的那一步——加载 OBJ、处理贴图、调光照、转视角、适配到页面里,每一步都有坑。这份“3dviewer”资源就是一个精简到极致的 JavaScript 版 OBJ 查看器,解决的就是“我手里有个 .obj 文件,我想在网页里拖动、旋转、缩放地看它”这件事。适合刚接触前端 3D 的开发者抄作业,也适合老手拿来当项目骨架改业务。它不是完整商业引擎,而是一个把核心链路理干净的起点。
2. 从 OBJ 到屏幕像素:查看器背后的解析原理与最小实现
2.1 OBJ 格式到底存了什么:顶点、法线、UV 与面索引
OBJ 是一种纯文本的 3D 模型格式,每一行是一个数据声明。最常见的几类记录是v(顶点坐标)、vt(纹理坐标)、vn(法线)、f(面),这三个字母分别对应几何数据、贴图映射和光照计算所需的信息。很多人在“写一个查看器”这件事上卡住,就是因为把 OBJ 想成了一种能被浏览器直接读取的二进制格式,实际上它只是一堆字符串。
可以拿一个最简单立方体的 OBJ 片段来说明。文件里v后面的三个数字分别对应 x、y、z 坐标;f后面的三组数字代表组成一个三角面的三个顶点,每组数字用斜杠分隔时,依次指向顶点索引、纹理索引、法线索引。注意这里索引从 1 开始,这是 OBJ 的老传统,和 JavaScript 数组里从 0 开始完全不同,解析时少做一个-1就会让模型错位甚至消失。
# 一个手动构造的三角形 v 0 0 0 v 1 0 0 v 0 1 0 vt 0 0 vt 1 0 vt 0 1 vn 0 0 1 f 1/1/1 2/2/1 3/3/1这段数据解析之后的逻辑是:取第一个顶点(0,0,0),对应的 UV 坐标是(0,0),法线是(0,0,1),然后依次取后两个顶点拼成一个三角形。这里的f行用的是“顶点/纹理/法线”全量写法,OBJ 还允许省略纹理坐标,比如f 1//1,解析器必须兼容这种缺省写法,否则很多从建模软件导出的文件会直接报错。
2.2 Three.js 里怎么加载 OBJ:OBJLoader 的用法与内部处理
自己手写一个完整的 OBJ 解析器工作量不小,更稳妥的做法是直接在官方加载器的基础上包业务逻辑。Three.js 自带的OBJLoader做的事情就是上面说的字符串解析:把每行拆开、把索引减一、按面组拼装 BufferGeometry,再交给 Mesh 渲染。加载器内部还会对顶点数据做去重处理和属性重组,这些细节不需要你操心,但理解了v、vn、vt和f之间的对应关系,你就知道加载器报错时问题出在哪个环节。
下面这段代码是基于 Three.js(r160+ 的写法)搭建一个查看器核心的场景、相机、渲染器和加载逻辑。这一步走通,你就能在页面上看到一个带材质的基础模型。
// 核心场景初始化:场景、透视相机、渲染器 const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000); camera.position.set(5, 5, 5); camera.lookAt(0, 0, 0); const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(window.devicePixelRatio); document.body.appendChild(renderer.domElement); // 用 OBJLoader 加载模型,返回 Group 对象 const loader = new THREE.OBJLoader(); loader.load('./model.obj', (group) => { centerAndScale(group); // 自定义:把模型移到原点并缩放到合适尺寸 scene.add(group); }, undefined, (error) => { console.error('OBJ 加载失败', error); });逻辑说明:创建的PerspectiveCamera是透视相机,效果和人眼观察一致,近大远小。antialias: true开启抗锯齿,避免模型边缘出现明显的锯齿状折线。loader.load的第一个参数是模型路径,第二个回调拿到解析完成的 Group 对象,第三个参数是加载进度回调,这里用undefined跳过,第四个是错误回调。拿到 Group 之后不能直接丢进场景完事,因为 OBJ 文件的坐标原点通常不在几何中心,需要做一次位置和尺度的归一化,也就是代码里centerAndScale这个函数要做的事。
2.3 让模型居中:包围盒计算与自动缩放
不同的 DCC 工具导出的 OBJ 尺度千差万别,有的单位是米,有的单位是厘米,有的建模时原点就不在世界中心。如果不做处理,模型要么跑到相机背后,要么大得整个屏幕只剩一个面。常规做法是通过Box3计算包围盒,再根据包围盒的中心和尺寸做平移和缩放。
function centerAndScale(obj) { const box = new THREE.Box3().setFromObject(obj); // 计算整个模型的包围盒 const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()); // 先平移到世界原点,保证旋转时模型不跑偏 obj.position.sub(center); // 取最长边做归一化,让模型在视口中大小稳定 const maxDim = Math.max(size.x, size.y, size.z); if (maxDim > 0) { const scale = 10 / maxDim; // 10 是目标显示高度,可按场景调整 obj.scale.setScalar(scale); } }逻辑说明:Box3.setFromObject遍历模型的所有子网格,计算出包含全部顶点的最小立方体。position.sub(center)是把模型整体位移到原点。缩放比例的计算用的是最长边除以目标尺寸,这样不管原始模型多大,放进场景后视觉大小都比较稳定。参数说明:这里的10是目标包围盒高度,具体值要根据你的相机位置来判断,相机距离模型越远,这个值可以越大;不建议直接写死,而是根据相机到原点的距离动态算。
3. 把缩放、旋转、平移拆开:交互控制与视角适配
3.1 轨道控制器:为什么不用手写鼠标事件
看模型的核心交互是“转着看”,也就是在球面上移动相机。自己监听鼠标事件去算球面坐标很容易,但边界情况特别多:鼠标滚轮缩放的速度、触摸板上双指缩放的惯性、旋转时俯仰角超过 90 度导致的视角反转。这些细节不需要重复造轮子,直接用OrbitControls就好,它是 Three.js 官方维护的轨道控制实现,支持鼠标左键旋转、右键平移、滚轮缩放。
接入方式不复杂,但有几个参数值得单独说。
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; // 把控制器绑定到相机和 renderer.domElement 上 const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; // 开启阻尼,旋转释放后有轻微惯性,手感更顺滑 controls.dampingFactor = 0.08; // 阻尼系数,值越小惯性越明显 controls.screenSpacePanning = true; // 平移时沿屏幕平面方向,而不是沿世界坐标轴 controls.maxPolarAngle = Math.PI; // 允许视角从正上方转到正下方,兜底拉满 controls.minDistance = 1; controls.maxDistance = 100;逻辑说明:OrbitControls接收相机和渲染器绑定的画布元素,之后所有的鼠标事件都由它内部处理。enableDamping开启后,每次相机位置变化都是平滑过渡的,但代价是动画循环里必须持续调用controls.update(),否则旋转会失效。参数说明:maxPolarAngle控制的是相机与垂直轴的夹角,默认值是Math.PI / 2,即只能转到水平面,不能转到模型下方;拉到Math.PI可以做到类似“看底部”的效果。minDistance和maxDistance限制相机到焦点的距离范围,避免缩放时相机穿模或退得太远丢失模型。
3.2 让模型摆正:按需求做几何对齐
设计师导出的 OBJ 经常是“歪着”的,原因在于建模软件的上方向不一致。有些软件以 Y 轴为上,有些以 Z 轴为上,导进 Three.js 这类以 Y 为上的引擎里,模型可能整个横躺。处理办法有两种,一是加载后直接旋转 Group,二是在导出时就设置好轴约定。查看器场景下,我更推荐前者,因为不改原始文件,排查问题更灵活。
// 如果模型躺倒了,优先尝试把 X 轴绕 Z 轴旋转 -90 度 if (needAxisCorrection) { obj.rotation.x = -Math.PI / 2; }逻辑说明:rotation.x是绕本地 X 轴的旋转角,-Math.PI / 2对应逆时针转 90 度。具体旋转哪个轴、往哪个方向转,取决于你的 OBJ 是从什么软件里导出的,没有放之四海皆准的固定值。常见的做法是做一个“轴纠偏”开关,提供 X、Y、Z 三个轴的 90 度旋转按钮,让用户自己调。注意旋转要放在平移和缩放之后处理,先居中、再缩放、最后旋转,顺序错了会出现旋转后模型不在原点的情况。
3.3 网格、坐标轴和背景:查看器的辅助信息层
很多查看器为了让用户感知模型的位置和朝向,会在场景里添加网格地面和坐标轴辅助。Three.js 提供了两个现成工具:GridHelper和AxesHelper。前者画一个网格平面,后者画三条表示 XYZ 方向的线。
// 地面网格:尺寸和分段数按场景大小设置 const grid = new THREE.GridHelper(20, 20, 0x888888, 0x444444); scene.add(grid); // 坐标轴:长度 5,红绿蓝分别对应 XYZ const axes = new THREE.AxesHelper(5); scene.add(axes);逻辑说明:GridHelper的两个核心参数是网格的总尺寸和分段数量。尺寸 20、分段 20,代表一个 20×20 的平面被切分成 20×20 的小格,每个小格边长 1。网格中心默认在原点,正好和模型居中后的位置对应。AxesHelper的颜色约定很容易记:X 轴红色、Y 轴绿色、Z 轴蓝色。如果模型加载后看起来是“反”的,看一眼坐标轴的方向就能判断是模型本身的问题还是相机视角的问题。
4. 从能看到能用的距离:扩展功能与加载器定制
4.1 材质处理:OBJ 里的 usemtl 与 MTL 文件的加载顺序
纯 OBJ 只定义几何,不定义颜色;颜色和贴图信息放在同名的.mtl文件里。OBJ 里的usemtl指令用来切换材质,但 OBJLoader 默认返回的 Geometry 没有材质时,Three.js 会套一个默认的灰色材质,这就是很多人加载 OBJ 后看到一片灰的原因。要正常显示贴图,需要配合MTLLoader先加载 MTL,再把材质赋值给模型。
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'; import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader.js'; // 先加载 MTL,再加载 OBJ,保证材质能正确引用 const mtlLoader = new MTLLoader(); mtlLoader.load('./model.mtl', (materials) => { materials.preload(); // 预加载所有贴图资源 const objLoader = new OBJLoader(); objLoader.setMaterials(materials); objLoader.load('./model.obj', (group) => { scene.add(group); }); });逻辑说明:加载顺序很重要,OBJ 内部引用了 MTL 定义的名字,所以必须先拿到 MTL,再加载 OBJ。materials.preload()会把 MTL 里引用的贴图文件全部加载到内存,这样 OBJ 加载完成后不需要二次等待贴图,模型出现时就是带材质的完整状态。注意.mtl文件和贴图文件(通常是.jpg、.png)的路径是相对于 HTML 页面所在目录解析的,不是相对于 OBJ 文件,路径写错在控制台里会报一堆 404。
4.2 拖拽文件进来:本地文件的加载与 Blob URL 处理
浏览器出于安全策略,页面脚本默认不能读取本地磁盘文件的完整路径。但如果用户主动拖拽一个.obj文件到页面窗口,你可以通过FileReader读取文件内容,再把内容转成 Blob URL,交给加载器使用。这意味着查看器可以做成完全离线的工具,不需要本地起服务,双击 HTML 就能用。
// 监听整个窗口的 dragover 和 drop 事件 window.addEventListener('dragover', (e) => e.preventDefault()); window.addEventListener('drop', (e) => { e.preventDefault(); const file = e.dataTransfer.files[0]; if (!file || !file.name.endsWith('.obj')) return; const url = URL.createObjectURL(file); // 创建临时的资源地址 loadObjFromUrl(url); URL.revokeObjectURL(url); // 加载完成后释放内存,避免泄漏 }); function loadObjFromUrl(url) { const loader = new OBJLoader(); loader.load(url, (group) => { centerAndScale(group); scene.add(group); }); }逻辑说明:URL.createObjectURL会在浏览器内存里创建一个短期有效的 URL,指向这个文件对象。这个 URL 只在当前页面会话内有效,所以不用担心泄漏到外部。revokeObjectURL建议在模型加载完成后再调用,调早了文件数据会被释放,加载直接失败。还有一个容易被忽略的问题:拖拽进来的 OBJ 如果引用了 MTL 文件,Blob URL 方案无法同时处理多个文件,这时可以读取文件内容为文本,手动改写模型里的贴图路径为已经加载过的图片 URL,处理起来比较绕。我的建议是拖拽场景只支持纯几何预览,带贴图的模型交给路径加载模式。
4.3 显示模式切换:线框、光照与逐面着色
3D 预览场景里,用户经常需要在“效果预览”和“结构检查”之间切换。线框模式能直观看出模型的拓扑结构,对检查破面、三角化效果非常有用。One approach 是直接切换材质的wireframe属性。
// 遍历模型所有 Mesh,统一切换线框状态 function setWireframe(root, enabled) { root.traverse((child) => { if (child.isMesh) { child.material.wireframe = enabled; } }); }逻辑说明:traverse会递归遍历场景树中的所有子对象,通过isMesh判断是否是可渲染的网格对象,然后统一设置材质上的wireframe开关。这个方案改的是材质属性,不需要重建几何体,所以来回切换速度极快。参数说明:如果模型每个 Mesh 有多个材质组,需要遍历child.material为数组时循环设置,一个常见的翻车点是模型有几个材质组、material是数组,直接赋值会报错。
5. 查看器避坑指南:现象、原因与解决办法
5.1 模型加载后是灰色的
现象:场景正常显示,模型也出现了,但没有纹理贴图,统一是灰白色。原因:OBJLoader 默认给模型赋予的是MeshPhongMaterial基础材质,且路径模式加载时没有预先加载 MTL 文件,材质组没有纹理映射。解决:按第 4.1 节的逻辑,用MTLLoader加载同目录的.mtl文件,然后通过setMaterials注入。如果确认有 MTL 但颜色不对,检查贴图引用路径是不是相对于 HTML 页面目录写的,这是最常见的 404 根源。
5.2 模型一闪就消失或旋转时被裁切
现象:初始视角能看到模型,稍微拖动视角模型就消失了一半,或者靠近后发现被切了一个面。原因:相机near和far裁剪面设置不合理。near设置太大会把靠近相机的部分裁掉,far设置太小会把远处的部分裁掉。解决:把近裁剪面设置为0.1,远裁剪面设置为1000,如果模型特别大或者相机距离特别远,适当把far放大到5000,但注意far过大会引起深度缓冲精度问题,表现为远近两个模型闪烁重叠。
5.3 模型位置不正,旋转时绕着奇怪的轴转
现象:模型加载后不在屏幕中心,旋转时绕着空间中的某个点转,不是模型自身转。原因:模型的包围盒中心不在原点,OrbitControls的旋转焦点默认在原点。解决:在centerAndScale里做包围盒中心归零,把模型整体平移到原点。注意要先平移再缩放,平移用的是世界坐标,缩放用的是本地坐标,顺序反了会导致模型中心始终偏离原点。
5.4 加载大体积 OBJ 时页面卡死
现象:几十 MB 的 OBJ 文件加载后,页面点击旋转一卡一卡,帧率极低。原因:OBJ 文件里顶点数量巨大,几何体又常带大量重复顶点,BufferGeometry 未做顶点合并。解决:在模型加载完成后调用BufferGeometryUtils.mergeVertices合并重复顶点,能显著降低顶点数量。另外,把模型的 Matrix 在加载时就应用掉,避免渲染时每帧做矩阵计算,也是一个有效手段。
import { mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js'; // 合并顶点,避免重复顶点撑爆 GPU 缓冲区 root.traverse((child) => { if (child.isMesh) { child.geometry = mergeVertices(child.geometry); child.geometry.computeVertexNormals(); } });逻辑说明:mergeVertices会把距离极近的顶点合并为一个索引,核心参数是tolerance容差,conceptually 即两个顶点距离小于某个阈值时认为是同一个点。默认值是Number.EPSILON,即只合并精确重复的顶点。对于建模软件导出的 OBJ,建议把容差适当调大,比如1e-4,因为浮点精度差异会制造大量“看起来是同一个点但数值不完全相等”的冗余顶点。参数说明:容差单位是模型自身的空间单位,模型尺寸特别小(比如毫米级)时,1e-4几乎等于不合并,要根据场景调整。
5.5 拖拽文件能看到模型,但刷新后没了
现象:把 OBJ 文件从桌面拖进浏览器,模型正常显示,但一旦刷新页面,模型消失,控制台里报错。原因:浏览器安全策略禁止页面在没有用户操作的情况下自动读取磁盘文件,刷新后拖拽操作不存在,文件对象自然不存在。解决:这不是代码 bug,而是 Web 平台的权限边界。如果你需要一个“持久化”的离线查看器,建议做成 Electron 壳应用,或使用 File System Access API 请求用户授权保存句柄,授权后可以重新读取文件内容。
6. 验证与调优:把查看器做成可靠日常工具的最后一公里
6.1 加载完成的判定标准
“能加载”不一定是“加载成功”,有一套维度可以验证。模型出现后旋转 360 度,看是否有模型断裂或缺口;切换到线框模式,看是否出现非三角面的多边形(OBJ 允许四边形和五边形,但 WebGL 只渲染三角面,加载器会自动拆分);检查控制台是否输出材质贴图的 404 警告;逐项绕过错误回调是否触发。我一般是写一个 debug 配置项,启动时自动输出模型顶点数、三角形数、纹理数量三个指标,和建模软件里的统计对一下,差太多就说明加载链路有问题。
6.2 参数调优的经验值
查看器落地到不同页面上,有四个参数我每次都会调:相机视野fov决定构图效果,小视野适合展示模型细节,角度越大场景越显透视感;controls.dampingFactor调整操控手感,我个人喜欢0.06,手感更沉更稳,而0.15就比较飘;GridHelper的网格颜色我一般配合页面主题色做弱化,避免抢掉模型的视觉焦点;maxPolarAngle看需求,展示类页面我会限制在Math.PI / 2,避免用户把模型翻转到底部看到穿帮面,但工具类页面会拉满。
6.3 性能检查的路径
一个有效的方法是打开开发者工具的 Performance Monitor,记录加载动画后的帧率,对比开启阻尼和关闭阻尼时的 FPS 差异。若 FPS 接近 60,说明渲染链路健康;若低于 30,优先检查顶点数量和 draw call 数量;若是多材质 OBJ,考虑合并几何体材质组。我自己习惯做一个“双跑测试”:同一个模型在一台普通办公机上验证,再在集成显卡的笔记本上验证,如果两者都流畅,再放进业务项目里,因为渲染性能在低端设备上的表现才是真实下限。
做这个查看器的过程里,最深的教训来自懒加载。某次给一个跨平台系统做模型预览集成,图省事把 OBJLoader 的加载写成 async 后不等待生命周期钩子,结果在快速切换页面时模型被把 Group 移除,再切换回旧页面时加载回调又往场景里塞了一遍已经移除的 Group,出现重复渲染。从那以后我每次把外部模型资源与页面绑定,都会强制做一次注销卸载,在页面切换前把场景清空、控制器 dispose、几何体 geometry.dispose、材质 material.dispose,一条链跑完才放心。这些收尾工作看起来琐碎,但能避免掉九成以上的“偶发白屏”和“内存暴涨”类问题,希望帮到你。
本文还有配套的精品资源,点击获取