浏览器端 3D 看车重磅升级:从三款展车到可试驾的八车展厅
发布日期:2026-09-30
开源项目:3d-car-viewing
写在前面
上一篇 写到 2026 年 6 月,项目还是三款车:奥迪 Q3、宝马 M2、巴博斯 G900。那一版已经把「第三方 GLB 怎么归一化、怎么发现部件、发现不了就禁用按钮、加载失败就回退几何体」这条链路跑通了。
0.2.0 和 0.3.0(2026-09-29 至 09-30)要解决的是下一层问题:
车模网格是焊死的,场地却要能换;人要能坐进驾驶位,路还得在车轮下往后退。
这一轮的主线有四条:
- 阵容:小米 SU7 Ultra / YU7 / SU7 Max、奔驰 AMG G63、Jeep 牧马人,和原来的 Q3、G900、M2 放进同一套 Rig。
- 切开合并网格:一条 buffer 里的四只轮子要分开转;门皮、后视镜、门把手要从共享节点里挂到铰链上。
- 场地与光线正交:影棚、大厅、公路,各自都能切白天和夜晚。
- 试驾:关上车门、打着火、相机落到驾驶座,公路景观按车速反向滚动。
下面按升级结果和对应难点写。基础的归一化、capabilities 门控、离线 IBL,上一篇已经讲过,这里只写新长出来的部分。
效果预览
默认车型换成了小米 SU7 Ultra。常用操作收进画布:顶栏选场地和光线,左右轨开车门和视角,底部是车漆。

升级一览
| 模块 | 上一篇(约 0.1) | 0.3.0 |
|---|---|---|
| 车型 | 奥迪 Q3、宝马 M2、巴博斯 G900 | 八款。默认小米 SU7 Ultra,另加 YU7、SU7 Max、奔驰 AMG G63、Jeep 牧马人 |
| 车轮 | Q3 路面轮与车身焊在一起,展厅不转 | 跨轴 buffer 按四角切开,八款都能原地滚 |
| 车门 | 关键字命中整块 mesh 就转,太大的直接禁用 | 连通岛挂到铰链。G63 只开前门;牧马人四门加侧开尾门 |
| 车漆 | 改 paintMaterials 的颜色 |
烤进贴图的色相先抽掉,灰色拉花和黑色贴花留下;多一个「默认车漆」 |
| 场景 | 影棚 / 白天 / 夜晚三套互斥预设 | 场地 studio / hall / road × 光线 day / night |
| 相机 | 包围盒外景 + 环车巡检 | 驾舱按方向盘落座;拖拽时眼睛钉在座位上,只转头 |
| 驾驶 | 怠速微振、制动俯仰 | 试驾模式:前轮回正向前滚,车停在右侧第一车道,路景反向滑 |
| 加载 | LRU,遮罩盖在画布上 | 遮罩画在 WebGL 外面;正在看的车不会被预加载淘汰拆掉 |
| 分享 | `mode=day | night` |
一、八款车,能力按模型诚实开关
第三方 GLB 的命名和拆分方式仍然各写各的。这一轮没有追求「每辆车都能开门」,而是让 discoverAssetCarRig() 上报 capabilities,按钮跟键盘跟 mesh 点击走同一套门控。识别不了,或者产品上明确关掉的,按钮禁用并写明原因。
| 车型 | 这一版实际能做的 | 做不到的原因 |
|---|---|---|
| 小米 SU7 Ultra / YU7 | 前门、后备箱、天窗、车灯、改色、四轮、驾舱 | --- |
| 小米 SU7 Max | 车灯、改色、四轮 | 门、后备箱、天窗和车身是同一块网格 |
| 奥迪 Q3 | 门、后备箱、天窗、车灯、四轮 | 轮胎原本是一条跨前后轴的 buffer,运行时切开 |
| 奔驰 AMG G63 | 两扇前门、车灯、改色、四轮 | 尾舱焊在 RearKit 里,没有独立尾门 |
| Jeep 牧马人 | 四门、侧开尾门、车灯、改色、四轮 | 硬顶不滑动,所以没有天窗 |
| 巴博斯 G900 | 车灯、双闪、四轮 | 门皮和尾门能切出来,展厅里仍保持关闭 |
| 宝马 M2 | 车灯、改色、四轮 | 门、后备箱、天窗与车身合并;没有独立方向盘 mesh |
G900 是个刻意的产品取舍:自动发现已经能把门皮切出来,但原模的门和轮焊在同一套材质 buffer 里,打开之后缝和法线都不像样。car-categories.ts 用 disabledInteractions 把左门、右门、后备箱关掉,Rig 层的 capabilities 再开也不出按钮。


牧马人的后排门不单独占按钮。发现阶段把它们放进 companionDoorPivots,useFrame 里按 showroomSide 跟同侧前门一起阻尼打开。尾门铰链轴是 Y,往侧面甩,备胎留在尾门节点上一起走。
二、重难点:一条 buffer 里的四只轮子
上一篇里 Q3 标了 bakedWheels: true,因为路面轮和车身焊在一起,一转就是整车在转。M2 和 G900 也有同类问题:名字里带 wheel 的 mesh,包围盒同时盖住前轴和后轴。
2.1 按三角形重心分到四个角
splitSpanningWheelMeshes() 在 asset-car-rig/wheels.ts。只处理「名字像轮子,而且包围盒横跨车身」的 mesh。每个三角形取世界空间重心,跟整车中心比出 FL / FR / RL / RR,再把这组三角形抽成新的 mesh,挂回原来的父节点。原 mesh 删掉。
分完之后,每只轮子仍然用原来的 applyWheelMotion():从 userData.showroomWheel.base 矩阵重建,绕自身轴滚动、绕转向轴偏转,场景图里不加 helper 节点。
有两类三角形不能跟轮子走:
- 刹车卡钳。 Q3 的卡钳叫
Alloy_Break,M2 拼成Calliper。名字里带 caliper / brake disc / fender 的,发现阶段直接排除,否则轮子一转,卡钳跟着画圈。 - 备胎残片。 G900 会把几片备胎面塞进同一条漆面 buffer。四角分桶之后,用各角点到中位数轮心的距离找空隙:空隙够大,才把远离轮心的三角形留在车身上。中位数是为了不让高处的备胎把轮心拽偏;剪掉圆轮胎的上沿,滚动时圆心会飘,看起来像轮子在抖。
奥迪 Q3 停在公路右侧车道上,四只轮子已经是独立网格,侧视能对上轮拱。

2.2 大灯也是同一类「岛」
G900 的 lights_lod0 把圆灯、侧标志灯和尾灯打进同一条 buffer。若整份材质一起拉 emissive,保险杠和车尾会跟着亮。isolateOffroadHeadlampIslands() 只保留前角、且高度落在灯带而不是车顶灯条上的三角形。M2 的灯罩也按同样的思路从合并网格里拆出来。
三、重难点:车门只转该转的那一块
上一篇的 isLocalizedPanel() 能拒绝「脚印盖住半个车身」的 mesh,避免整车跟着门转。它解决不了另一种模型:一个节点里已经有好几块互不连通的岛,门皮、后视镜、门饰条和车厢中段混在一起。
3.1 连通分量,而不是按包围盒一刀切
connectedTriangleIslands() 用并查集:同一三角形的三个顶点并到一组,共享顶点的三角形留在同一个岛里。已经连在一起的面不会被切开。 每个岛算自己的包围盒,中心落在哪扇已关闭车门的盒子里,就把这一岛 adopt 到那扇门的 pivot 上。落在所有门之外的,例如座舱中段,留在车身上。
G63 更碎。后视镜壳、腰线、门把手下面的蓝色饰板,是共享节点里的独立岛,节点名又不是 ANC_Door。market-rig-profiles.ts 给这些岛写了形状约束:三角形数量、相对门板的尺寸比例、中心落在门板上的位置区间。对得上才挂到铰链,其余岛不动。顶点靠得很近、本应是同一块饰板的岛,会先按一个很小的焊接距离并起来,避免一块饰板拆成三片各转各的。
所以上面那张 G63 侧视里,两扇前门、门镜和门饰板一起打开,后排门和尾门仍然关着。后排本来就在 RearKit 里,没有单独的铰链可挂。
3.2 铰链要落在门轴上,而不是 mesh 原点
Q3 早期的门能转,但绕的是网格自身原点,开门时整扇门脱离门框平移。发现阶段现在把 pivot 放到车身铰链:沿门板前缘收回去,再沿车身外侧偏出几厘米。后备箱盖从车顶后沿掀起,而不是从保险杠中间折断。
牧马人的尾门同理,只是轴换成了 Y。几何体回退车模和 GLB 车模共用这一套 damp 插值,开合手感一致。
四、重难点:烤进贴图的车漆
上一篇改车漆,是给 paintMaterials 赋一个纯色。SU7 Ultra 的黄色是画在 albedo 贴图里的。Three.js 里 material.color 会和贴图相乘,色板再选,黄底都还在,选出来的颜色全被黄污染。
rewriteChromaticPaintAlbedo() 先抽样:高饱和且够亮的像素占比超过大约两成,才认定这是烤漆,而不是拉花或内饰。然后把「色相差得大、亮度又够」的像素写成白色,灰条和黑贴花的色差小,原样留下。中性贴图缓存到 userData,同一材质不会每次切色都读一遍 canvas。
色板里的「默认车漆」传 color = null,把 showroomBaseColor 和原始贴图设回去,工厂黄还在。

灯罩玻璃不在 paintMaterials 里,这条约束和上一篇一样:喷漆不染灯。
五、场地和光线拆开
上一篇的场景模式是三选一:影棚、白天、夜晚。白天公路和夜晚影棚没法组合,分享链接里的 mode 也同时承担了两件事。
现在是笛卡尔积。showroom-scene-modes.ts 用 ${venue}:${lighting} 取出六套灯光、雾和地面参数,背景网格另挂:
| 场地 | 组件 | 资产 |
|---|---|---|
影棚 studio |
showroom-studio-gallery.tsx |
white_round_exhibition_gallery.glb,白色圆形展厅 |
大厅 hall |
showroom-hall-scene.tsx |
car-showroom_1.glb,室内展台 |
公路 road |
showroom-road-scene.tsx |
程序化沥青,加上 realtime_grass.glb 和 tree_animate.glb |


公路的车位是写死的空间约定:展厅前方是 −X,车辆放在右侧第一车道,双黄线在车的左侧。试驾滚动时动的是路,不是这辆车的世界坐标,所以机位和开门铰链不用跟着平移。
夜晚把环境光压下去,头灯聚光强度从白天的十几拉到五十以上,光池打在沥青上。影棚夜晚仍走 MeshReflectorMaterial,移动端按 performanceTier 降反射贴图分辨率,这一点没改。
分享链接改成 mode=studio|hall|road 和 light=day|night。parseShowroomSceneQuery() 见到旧的 mode=day / mode=night 时,场地按影棚、光线按原值读,已经发出去的链接不会打开成空白场景。
路旁的树还在用已经废弃的 KHR_materials_pbrSpecularGlossiness。Three.js r183 之后的 GLTFLoader 不再内置这个扩展,不注册插件的话树是一片警告加丢贴图。registerSpecularGlossiness() 只做一件事:把扩展里的 diffuse 贴到标准材质的 map 上。树不需要把旧高光模型完整移植过来。
六、重难点:试驾时车不动,路在动
迎宾和试驾是两份状态快照,不是两套场景。showroom-presets.ts 把车门、灯、发动机、车速、相机、场地收成一个对象。当前状态和某份快照一致时,按钮高亮;用户再改任意一项,高亮消失。模型不支持的门,比对时忽略,避免「这辆车没有天窗,所以永远匹配不上迎宾」。
| 预设 | 状态 |
|---|---|
| 迎宾 | 两扇前门打开、车灯和双闪打开、发动机关闭、全景相机、人留在当前场地 |
| 试驾 | 门关上、灯开着、发动机打着、45 km/h、前轮回正、相机切驾舱、场地切公路 |

6.1 驾舱要坐在方向盘后面
外景机位继续用包围盒比例,换车不会构图散掉。驾舱不能再套这个公式,否则相机会落在引擎盖上或者后排。
getCockpitCameraPose() 以方向盘中心为锚。展厅前方是 −X,驾驶员在方向盘的 +X 侧,所以眼睛放在轮心后方(上限约 0.42 米)和上方。视线以大约 0.42 弧度的俯角看车头前的路面,风挡里要能看到地面。
两辆车的座舱网格和这套默认值对不上,写在 COCKPIT_PROFILE_TUNES 里:
- YU7:共享眼高会扎进方向盘上沿。眼睛抬到轮缘上方 0.24 米,俯角收到 0.12,视线从风挡出去,而不是打进轮圈。
- M2:没有独立方向盘 mesh。按包围盒比例把座位放在发动机罩之后、前舱座垫附近,轮圈进入画面下沿。
OrbitControls 默认绕 target 转。驾舱的 target 在车头前的路面上,用户一拖,眼睛会被甩出座椅。anchorCockpitOrbit() 把相机和 target 成对平移,眼睛留在入座时的那个点上,拖拽只改变视线方向。距离变化沿这条视线推拉,阻尼和极角限制仍然有效。

6.2 路景滚动,车轮只向前
试驾要求前轮回正,并且只向前滚。车身世界坐标不动,否则开门铰链、阴影和轨道目标都要跟着改。
showroom-drive.ts 用和轮速相同的阻尼把目标速度积出来,再换成路景位移:
ts
/** 展厅前方是 −X。轮子向前滚时,路要往 +X 退,看起来才是车在往前走。 */
export function sceneryShiftX(speedMps: number, deltaSeconds: number) {
return speedMps * deltaSeconds;
}
行道树、草和远山各自记一个 X,每帧加上这段位移,再用 wrapRange() 折回重复窗口。窗口长度是「这排物体的跨度再加一个间距」,物体滑出一端时从另一端补上,接缝处仍是一个间距。沥青贴图的 offset.x 用相反符号折回 [0, 1):Three.js 增大 offset 时纹理会往 −X 走,路景往 +X 退,offset 就要减小。
制动把阻尼从 4 提到 14,轮速和路景一起停,尾灯的红色自发光叠上去。这部分和上一篇的制动俯仰是同一条 useFrame。
七、加载、缓存和运行时
八款压缩后的 GLB 大约 91 MB,SU7 Ultra 单车大约 31 MB。上一篇的 LRU 和「新车就绪前旧车仍可见」还在,这一轮补了三处会在切换时露馅的问题。
遮罩不要画进 WebGL。 进度层原先跟在 Canvas 里面,透明区域也会吃掉指针,加载过程中轨道相机转不动。ShowroomAssetLoadingOverlay 现在是画布外的 DOM,pointer-events: none,加载时仍能拖视角。本地缓存命中时,遮罩仍会短暂停留,避免闪一下就没了。
正在展示的车不能被预加载淘汰。 空闲时 scheduleIdleGltfPreloads() 按带宽去暖其他车型。缓存超过上限时,只 dispose 屏幕外的那份 clone。现场那棵已经挂进场景的根节点单独钉住。另一处相关的坑是复用准备好的模型时,门、轮、天窗要复位,根节点的 position / rotation / scale 不能复位 :normalizeMarketModel() 已经把尺度和朝向烤进去了,清掉之后车会缩成一个点。
几何体回退要等所有候选都失败。 glbCandidateUrls() 先试当前车型,再按选择器顺序试其余七款。八个地址都失败,才切到程序化 CarModel。回退车的 capabilities 全开,演示按钮不会突然全灰。
帧循环也换了一处时钟。Three.js r183 起 THREE.Clock 会报警,React Three Fiber 仍会建一个。createFiberFrameClock() 维持 getDelta / elapsedTime 这套调用,内部改读 THREE.Timer,业务侧的阻尼和路景积分不用改。
资产侧,pnpm compress:models 用 Draco 压 market GLB,解码器放在 public/draco/gltf,不走外部 CDN。这和上一篇的 RoomEnvironment IBL 是同一条约束:内网和离线部署不能依赖第三方 HDR 或解码器地址。
Rig 代码从单文件拆进 src/lib/asset-car-rig/:discover、body、wheels、lights、materials、mesh。对外仍是 @/lib/asset-car-rig。每款新车有一份 fixture 测试,锁住门、轮、灯和驾舱落点,避免下一次 Profile 正则把已经打开的门再次焊回去。
八、已知限制
- 试驾是路景滚动,车辆没有真实位移,也没有转向和车道保持。前轮在试驾里保持回正。
- 和车身焊死的门仍然打不开。SU7 Max、M2 的门,G63 的尾门,按钮保持禁用。
- G900 的门和尾门技术上能切,展厅里故意关闭。
- 驾舱质量取决于有没有方向盘 mesh。没有的车靠包围盒比例估一个座位,和真车眼点仍有偏差。
- 首辆 SU7 Ultra 大约 31 MB,第一次打开要等数秒。生产环境仍建议 CDN 和更狠的纹理压缩。
- 截图继续依赖
preserveDrawingBuffer,高分辨率移动端有内存压力。
总结
上一篇把不可控的 GLB 翻译成「可发现的能力」。这一轮是在发现层上面继续切:
- 网格切分把焊死的轮子和车门岛变成可动画的部件,切不开的能力继续关掉。
- 场地 × 光线把背景和光照拆成两根轴,六套组合共用同一份车和同一套相机。
- 试驾把驾舱落座、轮速和路景位移绑在同一条帧循环里,车本身留在车道原点。
如果要加第九款车,路径和上一篇相同:GLB 放进 public/models/market/,在 car-categories.ts 注册,自动发现失准时补 MarketRigProfile。差别在于现在要同时看四件事:轮子要不要切、门是不是连通岛、车漆是不是烤在贴图里、驾舱有没有方向盘可以坐。
本地体验
bash
git clone https://github.com/jiaxiantao/3d-car-viewing.git
cd 3d-car-viewing
pnpm install
pnpm dev
建议按这个顺序看升级点:
- 默认 SU7 Ultra,打开迎宾模式,看前门、车灯和双闪。
- 顶栏从影棚切到大厅,再切到公路。同一辆车,背景和灯光分开变。
- 选试驾模式,确认人坐在方向盘后面,拖拽时人还在座位上。再切到夜晚前脸,看头灯光池。
- 换成奔驰 G63,只开前门;换成牧马人,打开尾门,看备胎跟着侧开。
- 回到 SU7 Ultra,在默认车漆和熔岩红之间切换,深色拉花还在。
分享链接形如:
?model=su7-ultra&paint=factory&camera=overview&mode=studio&light=day
延伸阅读
- 浏览器端 3D 看车:从 GLB 到可交互展厅的技术实践 --- 归一化、Rig 发现、capabilities、离线 IBL
- 浏览器 3D 项目 GLB 工程化全链路 --- 分析、压缩、降级
- 浏览器端 3D 场景性能优化实战 --- Draco、LRU、首屏
- 3d-car-viewing 仓库 --- 源码与 CHANGELOG
- 在线演示