☰
Three.js打造可交互3D教室:场景搭建到InstancedMesh与Raycaster落地
2026/10/13 20:22:27 网站建设 项目流程

最近接了个挺有意思的需求:把一间真实教室做成浏览器里能看的3D模型,支持旋转查看、选中课桌、弹出座位信息。我用Three.js从头搭了一整套方案,从建模思路到交互实现都踩了不少坑,这篇就把完整链路拆开讲——从场景初始化、墙体门窗搭建、课桌椅批量生成,到射线拾取、实例高亮的实现,再到最后调帧率、修法线这些上线前的问题。如果你正准备用Three.js做室内场景可视化,或者想搞懂InstancedMesh、Raycaster这些核心模块怎么真正落地,这篇应该能帮你少走一段弯路。

1. 一个“教室里有什么”的需求,怎么拆成Three.js能画的东西

接到需求先别急着写代码。我习惯先拿一张纸,把教室里肉眼可见的东西全部列一遍:地面、天花板、四面墙、窗户、门、黑板、讲台、课桌椅、灯管,可能还有时钟、开关面板、消防器材。再把“能交互的对象”单独挑出来,比如课桌要能点选、能高亮,这些才是业务核心。

这个拆分动作看着简单,实际上决定了后面所有代码的边界。桌子的尺寸、椅子的间距、讲台离黑板的距离,都是后续要反复用的参数,越早定下来越好。我当时的做法是直接把教室长宽高设为10米、7米、3.3米,单位统一成“1单位=1米”。这样整个场景里的坐标几乎不用换算,比如“讲台前沿离黑板0.8米”直接写成pos.z = 0.8,代码读起来就像在看施工图,后面接数据接口也省事。

1.1 为什么选Three.js,而不是原生WebGL或游戏引擎

如果是十年前,这种事只能硬啃原生WebGL,一个立方体都要几十行着色器代码。现在Three.js已经是Web端3D事实上的标准了,它把渲染管线、矩阵变换、光照模型全部封装成了好用的API,社区里能搜到的案例也最多,遇到问题查起来效率高。Babylon.js其实也很强,但如果你团队里没人用过,学习成本和资料密度都不如Three.js。

游戏引擎(比如Unity打包WebGL)倒是效果上限高,但光一个运行时包就一两兆起步,页面首次加载体验很糟,还要维护一整套编辑器资产管线,对这种轻量可视化需求来说杀鸡用牛刀。Three.js只需一个JS库就能跑起来,配合项目现有的前端构建流程最顺。

1.2 建模的两种路线:代码建模还是外部资产

做3D教室有两条路:找美术用Blender、SketchUp这些工具做精细模型,再导出glTF/GLB格式加载;或者直接用Three.js内置几何体在代码里搭。很多新手一上来就想找模型资产,但教室这种场景的家具都是规则体块——长方体桌面、圆柱桌腿、平板椅面——用BoxGeometry、CylinderGeometry拼装完全够用。代码建模最大的好处是:尺寸是参数化的,可以随时调整行列数、桌椅间距,还能在生成时直接和业务数据绑定。我最终选择了代码建模,只贴了一层极简材质,场景干净,性能也好控。

2. 场景骨架的一次性配置:渲染器、相机、灯光和控制器

这部分是每个Three.js项目都要写的“地基”,但教室这个场景有几个特殊点值得提前说。灯光不能像普通展示场景那样随便打,否则学生座位区域全是阴影或过曝;相机控制器的俯仰角度必须限制,不然用户能钻到地板下面去;背景色调也不该用纯黑,教室里应该是明亮的、偏暖调的感官。

2.1 渲染器与坐标系的约定

我的渲染器初始化代码大致是这样的:

import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; document.body.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe8e4da);

坐标系的约定也很关键。我定的规则是:Y轴向上,X轴沿教室宽度方向,Z轴沿教室长度方向,地面在Y=0。相机初始放在教室斜前方的上方,能看到全景:

const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(12, 9, 12); camera.lookAt(0, 1.4, 0);

近裁面和远裁面设成0.1和100就够了,教室这种尺度下完全不会出现深度冲突的问题。透视相机的视角50度是一个安全值——太宽容易变形,太窄显得空间拥挤。

2.2 教室灯光的组合策略

灯光方面,我建议直接放弃AmbientLight这种均匀补光,改用半球光加平行光的组合。半球光可以模拟天光和地面反射光之间的过渡,让场景有最基本的明暗层次;平行光作为主光源,负责把所有几何体的立体感拉出来。再配合一两个点光源模拟教室灯管的向下照明,效果就很接近真实教室了。

const hemi = new THREE.HemisphereLight(0xffffff, 0x404040, 1.0); scene.add(hemi); const sun = new THREE.DirectionalLight(0xfff2e0, 1.8); sun.position.set(10, 14, 8); scene.add(sun);

注意MeshStandardMaterial对光的反应比较真实,光强参数要反复调,调光的时候最好开着renderer.useLegacyLights = false的现代模式。我实测中光是平行光强度1.2左右已经够亮,太强会让墙面过曝发白,丢失细节。另外,点光源别放太多——每个光源都会增加渲染开销,教室这种场景三个以内足够了。

2.3 轨道控制器的边界设置

OrbitControls几乎是必装的,但必须给它套上“笼子”:

const controls = new OrbitControls(camera, renderer.domElement); controls.target.set(0, 1.2, -1); controls.enableDamping = true; controls.dampingFactor = 0.08; controls.minDistance = 2; controls.maxDistance = 30; controls.maxPolarAngle = Math.PI / 2.05; controls.update();

这里把maxPolarAngle略小于90度,用户就没办法把相机压低到地面以下,也就不会出现“透过地板看天花板”这种尴尬视角。minDistance设2米,防止用户怼进椅子模型内部出不来。这些琐碎的边界条件看起来不显眼,但不设好的话,交付给用户的第一印象就是“这场景看着好怪”。

3. 教室空间体块:不挖洞的墙体方案与门窗排布

很多新手搭房间模型,第一反应是拿一个大盒子当房间:六面墙拼成一个封闭箱体,然后在某面墙中间挖一个窗户洞。听起来很直观,但问题是Three.js默认几何体没有“布尔挖洞”这种操作,想要方方正正的窗洞,要么引入CSG(构造实体几何)扩展库,要么手工把墙体拆成几段来拼。CSG库我没少用,但生成的几何体顶点非常碎,后期如果想给墙加厚度或做倒角,麻烦事一堆。教室这种规则场景,用“墙体分段拼接”的方案最舒服。

3.1 把墙拆成窗间墙、窗上墙、窗下墙

我的做法是根本不做完整的一大面墙,而是把一面带窗户的墙,拆成几个独立的PlaneGeometry片段。比如南墙有三扇1.5米宽、1.5米高的窗户,窗台离地0.9米,那这面墙就由这些部分组成:窗下墙(从地面到0.9米处)、窗间墙(两窗之间的立柱)、窗上墙(窗顶到天花板)、左右墙段,以及窗户本身的玻璃和窗框。

每个片段都是一个独立网格,材质统一用一堵米白色内墙色。这样做的直接好处是:不需要任何布尔运算,几何体全是简单的四边形,Draw Call和顶点数量都很可控。坏处是,墙体的背面是空的,如果相机穿进墙里会看到“透明墙”。所以材质要设成双面渲染,或者干脆限制相机不许穿过墙体。

3.2 窗户玻璃的通透与窗框装饰

窗户玻璃我用一个半透明的蓝色材质来做:

const glassMat = new THREE.MeshStandardMaterial({ color: 0xbfe3f7, transparent: true, opacity: 0.45, side: THREE.DoubleSide, roughness: 0.1, metalness: 0 });

透明物体在Three.js里会涉及深度排序问题,同一个位置如果有多个透明物体重叠,可能会出现半透明层级错乱。教室场景玻璃数量不多,出现概率小,但保险起见我会把玻璃的depthWrite设为false,让透明物体不至于互相遮挡。窗框则简单地用深灰色BoxGeometry做几根细长条,套在玻璃四周,视觉上立刻就有了“窗子”的样子。

3.3 地面、天花板和墙面的材质分级

地面我用一个大PlaneGeometry,铺的是浅灰偏暖的木纹色。谨慎起见没贴真实纹理图片,直接用纯色加一点roughness,因为教室地面大面积铺木纹贴图很容易显得脏,纯色反而干净。天花板用更浅的白色,反射率低一点,让灯光照射后不会刺眼。四面墙、地面、天花板的材质应该有所分级——墙面是漫反射最强的,地面稍微带一点光泽,天花板接近哑光。这个细节决定了整个空间的光感是否舒适。

4. 课桌椅批量生成:把上百次Draw Call压成两次

教室里最耗性能的地方,不是墙体,而是几十套课桌椅。如果每张桌子都单独建一个Mesh,一个小班50套桌椅就意味着50个网格对象,加上椅子、书本、桌腿,画面里的Draw Call直接飙到一两百。对于浏览器渲染来说,这个数量级很容易让帧率掉到30以下,尤其是还开着抗锯齿和高像素比的时候。

4.1 单个课桌椅的部件拆分

我先把一张课桌拆解成了几个几何体:桌面是0.9米x0.03米x1.2米的BoxGeometry,四个桌腿是0.05米x0.7米x0.05米的细长柱子,再加上一个书斗(开口的储物箱)。椅子则是座面、靠背、四根腿。每个部件单独创建后,用translate方法移动到正确位置:

const parts = []; const top = new THREE.BoxGeometry(0.9, 0.03, 1.2); top.translate(0, 0.75, 0); parts.push(top); for (let i = 0; i < 4; i++) { const leg = new THREE.BoxGeometry(0.05, 0.7, 0.05); const x = (i % 2 === 0 ? -1 : 1) * 0.4; const z = i < 2 ? -0.5 : 0.5; leg.translate(x, 0.35, z); parts.push(leg); } const merged = mergeGeometries(parts);

这里引入了mergeGeometries工具,把桌面、桌腿合并成一个几何体。如果没有这一步,一个Mesh只能挂一份几何体,课桌四腿一桌面就得拆成5个网格,最终变成了200多个Mesh。合并之后,一张桌子的所有部件变成一个几何体,一份材质就能渲染。

4.2 InstancedMesh:一次提交,多次绘制

合并几何体只是第一步。即便一张桌子只是一个网格,50张桌子仍然是50个网格对象。更优解是用InstancedMesh,它允许你用一套几何体和材质,在GPU层面批量渲染大量相同形状的物体:

const count = rows * cols; const deskMesh = new THREE.InstancedMesh(merged, deskMat, count); const matrix = new THREE.Matrix4(); const pos = new THREE.Vector3(); const quat = new THREE.Quaternion(); const scale = new THREE.Vector3(1, 1, 1); for (let i = 0; i < rows; i++) { for (let j = 0; j < cols; j++) { pos.set( j * 0.85 - (cols * 0.85) / 2 + 0.425, 0, 2.2 + i * 1.0 ); matrix.compose(pos, quat, scale); deskMesh.setMatrixAt(i * cols + j, matrix); } } deskMesh.instanceMatrix.needsUpdate = true; scene.add(deskMesh);

这样真正提交给GPU的,就变成了一张课桌的几何数据加上50个变换矩阵。渲染器只需调用一次绘制指令,就能把所有课桌画出来。我在测试时发现Draw Call从之前的几百降到了几十,帧率稳定在60。这个优化思路,在后续任何有重复物体的场景里都适用——路灯、树、管道、货架,一个道理。

4.3 排布算法:行距、列距与过道预留

桌椅排布不完全是等距网格。真实教室中间会留一条过道,两侧靠墙,所以我把列分成左中右三组,中列略偏后,中间留出1.2米的过道。算法上就是一个双层循环,按行号和列号计算坐标,注释里写明每一行的用途。做完这一步,整个教室就有了“秩序感”,坐在座位上的虚拟学生数据后面要绑定到这里,每个实例的下标就是座位号,对应关系必须事先列清楚。

5. 讲台黑板和灯具:决定教室气质的那几个物件

墙体和大批课桌椅做完,教室里已经能看出空间轮廓了,但要让它“像个教室”,还差几个点睛的物件:黑板、讲台、讲桌、灯管。这些单体模型不需要批量实例化,但也别一个个原样堆砌,要有材质上的区分和空间位置的逻辑。

5.1 黑板与讲台的空间关系

黑板是教室正前方最大的视觉焦点。我给的尺寸是长4米、高1.2米、厚0.05米,墨绿色表面配深灰边框,外加一根粉笔槽。材质上把roughness调高到0.9,这样黑板不会有刺眼的反光,整体气质更真实。黑板上沿离地约2米,下沿离地0.8米,这个高度正好避开讲台遮挡。

讲台是一个0.2米高、1.6米见方的平台,比地面颜色深一个色号。讲桌放在讲台正中间,是一张高度0.9米的小桌,配一把稍微不同的椅子,以示“教师专用”。空间逻辑上,黑板居中,讲台在黑板正前方偏下,讲桌再往教室方向推0.5米,三者的中心轴线对齐。轴线对齐这个细节很多新手会忽略,结果黑板在左、讲台在右,怎么看怎么别扭。

5.2 灯具:渲染用的灯和看得见的灯要分开

教室里需要“看得见的灯管”,也需要“算光照的灯光”,这两者在Three.js里是两套体系。看得见的灯管,我用几根扁长的发光材质BoxGeometry排成三列;负责照亮场景的,则是一盏从天花板向下打的点光源或聚光灯。

现实里我最终只用了两三盏点光源来模拟顶灯照明,其余全交给半球光和平行光兜底。因为点光源加多了,每盏都要参与逐像素光照计算,性能开销直线上升。灯管模型本身的MeshStandardMaterial我设了emissive自发光属性,即使没有实际光源照到它,看起来也是亮着的。这套“视觉灯管+少量实际光源”的组合拳,在保证视觉可信的同时,把性能代价压到了最低。

5.3 墙面点缀物品的原则

黑板旁边我挂了一个圆形的时钟,用CircleGeometry加一个细圆环边框做成,数字没做贴图,就用刻度小方块示意;教室后墙放了两个灭火器箱,用红色BoxGeometry加白色小字板拼出来的;门口旁边还有一个开关面板。这些物件加起来也就十几个网格,完全不影响性能,但能显著提高场景的真实感。

点缀的原则是:宁缺毋滥。室内场景最容易犯的错就是什么都想放进去,结果物件之间互相遮挡,用户根本分不清主次。我的习惯是每面墙最多放一到两个“装饰性物件”,其余空间留白,让视觉焦点始终停在课桌和黑板上。

6. 让教室可交互:射线拾取、实例高亮与视角转场

建模只是第一步,用户真正关心的往往是“能不能点一下课桌就显示谁坐在那”。这部分涉及Three.js的射线拾取和交互反馈,也是整个项目里避开坑最多的地方。

6.1 Raycaster:从鼠标位置反推一条射线

Three.js的Raycaster是拾取的核心工具。原理很简单:鼠标在屏幕上有一个二维坐标,把屏幕坐标转换成标准化设备坐标,然后从相机出发,沿着这个方向射出一条射线,看它能穿过哪些物体:

const raycaster = new THREE.Raycaster(); const mouse = new THREE.Vector2(); window.addEventListener('mousemove', (e) => { mouse.x = (e.clientX / window.innerWidth) * 2 - 1; mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; raycaster.setFromCamera(mouse, camera); const hits = raycaster.intersectObject(deskMesh); // ... });

这里有一个很容易踩的坑:InstancedMesh虽然可以整体拾取,但你要拿到“具体是哪一个课桌被点到”,必须用intersectObject返回结果里的instanceId。所以拾取对象要传课桌的InstancedMesh本身,不能传合并后的其它网格。如果场景里还有椅子、讲台的实例网格,记得分别做intersectObjects,或者把它们统一放进一个数组里。

6.2 实例高亮:改颜色而不是换模型

拿到instanceId之后,高亮这个实例有两种做法。最笨的是把这个课桌实例替换成一个独立的发光网格,但这样会破坏InstancedMesh的批渲染结构,得不偿失。正确做法是直接修改实例的颜色属性:

if (hits.length > 0) { const id = hits[0].instanceId; deskMesh.setColorAt(id, new THREE.Color(0xffd166)); deskMesh.instanceColor.needsUpdate = true; }

setColorAt是InstancedMesh专门提供的方法,它会在内部维护一个instanceColor属性,叠加在基础材质颜色之上。鼠标移开时,再把颜色重置回默认值即可。实测中这个方案非常丝滑,因为颜色修改只在顶点着色器层面发生,不产生新的Draw Call。

6.3 点击弹出座位详情

点击时我把instanceId映射到座位数据数组,比如座位号、学生姓名、考勤状态。这些数据用异步接口提前加载到内存,点击时只是查表展示,完全不需要重新请求。前端面板就是一个绝对定位的div,显示在点击坐标附近:

deskMesh.userData = seatDataArray; // 按下标存好 // 点击事件里 const seat = deskMesh.userData[hits[0].instanceId]; showPanel(seat.studentName, seat.seatNo, seat.status);

座位数据可以是静态JSON,也可以对接真实教务系统,只要保证下标和setMatrixAt的循环顺序一致就行。这个细节我在第一次接数据时吃过亏——数据顺序和实例渲染顺序错位,导致点第三排第五列,弹出来的是第一排第二列的姓名,排查了很久才发现是循环下标没对齐。

6.4 相机视角转场:从全景飞到单桌

交互的高级形态是点击课桌后,视角从鸟瞰全景平滑“飞”到这张课桌附近。你可以手动写一个线性插值循环,也可以引入tween库。我这里用手写的方式,因为依赖更少控制更直接:

function flyTo(targetPos, lookAt, duration = 800) { const startPos = camera.position.clone(); const startTarget = controls.target.clone(); const startTime = performance.now(); function tick(now) { const t = Math.min((now - startTime) / duration, 1); const eased = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; camera.position.lerpVectors(startPos, targetPos, eased); controls.target.lerpVectors(startTarget, lookAt, eased); controls.update(); if (t < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick); }

注意飞行动画过程中要暂时把controls.enabled设为false,否则用户一边拖拽一边飞,两个系统抢相机控制权,画面会抽搐。飞行结束要记得把控制权还回去,再调用一次controls.update()。

7. 上线前夜:帧率、法线、贴图三个方向的排查记录

整个项目做完,最容易在最后阶段翻车的三件事:帧率不够、模型某面变黑、贴图或材质显示异常。我把这三次踩坑的过程完整记录下来,你如果也遇到类似症状,可以直接对号入座。

7.1 帧率不足:先看Draw Call,再数点光源

第一版代码跑起来,拖拽时帧率只有20多帧,根本没法交差。我打开Three.js内置的renderer.info,看到Draw Call高达两百多次,罪魁祸首就是课桌椅没有合并、也没有实例化。改用InstancedMesh之后,Draw Call骤降到个位数,帧率立刻拉回60。如果你改了实例化仍然卡,下一步检查点光源数量——每盏点光源在片元着色器里的循环计算开销足以让帧率掉10帧以上。

7.2 法线反转导致墙面发黑

第二次问题是教室内部看过去,北墙有一大片黑斑,怎么调光都没用。排查后发现是墙体分段拼接时,某一段PlaneGeometry的法线方向指向了外面,从室内看它的正面是背面,背面不参与光照就黑了。解决方法是把材质side设为THREE.DoubleSide,或者旋转该段墙体让法线朝内。更稳妥的做法是建墙时统一用BoxGeometry,哪怕厚度只有0.1米,也不会有法线朝向问题。墙面不厚,多一点顶点完全可忽略。

7.3 贴图纹理加载失败或发灰

做黑板文字时,我一开始打算贴一张文字纹理图,结果怎么加载都是灰块,控制台报了跨域错误。后来发现是本地开发用file://协议打开页面,纹理请求被浏览器拦截了。解决办法很简单:起一个本地静态服务(比如任意一个前端开发服务器)再访问。另一个坑是贴图颜色发灰,原因是renderer.outputColorSpace没设置成SRGBColorSpace,导致颜色空间线性化后整体偏灰。把所有贴图的colorSpace也同步设置一下,颜色就恢复正常了。

7.4 透明玻璃层级混乱

最后一个小问题:玻璃和窗框重叠时,偶尔会出现玻璃跑到窗框前面的现象,半透明物体互相遮挡很“透”。给透明材质设了depthWrite: false之后,情况好转,但也要控制透明物体的互相穿插次数。室内场景里玻璃数量不多,完全可以通过合理摆放规避,不必上复杂的深度排序算法。

写在最后

我做这个3D教室项目最大的体会是:可视化不只是把几何体堆进场景,而是把真实世界的空间信息结构化——课桌的instanceId对应到座位数据,墙体分段对应到真实的门窗位置,灯光布局对应到教室照明常识。先把这些逻辑理顺,Three.js的代码只是表达的最终手段。

如果你也要做类似的室内可视化,我的建议是从最小的场景起步:先搭一面墙、一张桌子,跑通了再加四面墙和五十张桌子,每一步都确认性能和数据绑定正确,最后再考虑交互和动效。模块越早验证,后面越不容易翻车。这个项目后续还可以扩展楼层切换、门禁联动、人流动线模拟,甚至对接BIM数据做运维管理,核心的那套场景搭建和交互框架都是可以复用的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询