浏览器端 3D 看车重磅升级:从三款展车到可试驾的八车展厅

浏览器端 3D 看车重磅升级:从三款展车到可试驾的八车展厅

发布日期:2026-09-30

开源项目:3d-car-viewing

在线演示:jiaxiantao.github.io/3d-car-view...

上一篇:浏览器端 3D 看车:从 GLB 到可交互展厅的技术实践

写在前面

上一篇 写到 2026 年 6 月,项目还是三款车:奥迪 Q3、宝马 M2、巴博斯 G900。那一版已经把「第三方 GLB 怎么归一化、怎么发现部件、发现不了就禁用按钮、加载失败就回退几何体」这条链路跑通了。

0.2.0 和 0.3.0(2026-09-29 至 09-30)要解决的是下一层问题:

车模网格是焊死的,场地却要能换;人要能坐进驾驶位,路还得在车轮下往后退。

这一轮的主线有四条:

  1. 阵容:小米 SU7 Ultra / YU7 / SU7 Max、奔驰 AMG G63、Jeep 牧马人,和原来的 Q3、G900、M2 放进同一套 Rig。
  2. 切开合并网格:一条 buffer 里的四只轮子要分开转;门皮、后视镜、门把手要从共享节点里挂到铰链上。
  3. 场地与光线正交:影棚、大厅、公路,各自都能切白天和夜晚。
  4. 试驾:关上车门、打着火、相机落到驾驶座,公路景观按车速反向滚动。

下面按升级结果和对应难点写。基础的归一化、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 正则把已经打开的门再次焊回去。


八、已知限制

  1. 试驾是路景滚动,车辆没有真实位移,也没有转向和车道保持。前轮在试驾里保持回正。
  2. 和车身焊死的门仍然打不开。SU7 Max、M2 的门,G63 的尾门,按钮保持禁用。
  3. G900 的门和尾门技术上能切,展厅里故意关闭。
  4. 驾舱质量取决于有没有方向盘 mesh。没有的车靠包围盒比例估一个座位,和真车眼点仍有偏差。
  5. 首辆 SU7 Ultra 大约 31 MB,第一次打开要等数秒。生产环境仍建议 CDN 和更狠的纹理压缩。
  6. 截图继续依赖 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

建议按这个顺序看升级点:

  1. 默认 SU7 Ultra,打开迎宾模式,看前门、车灯和双闪。
  2. 顶栏从影棚切到大厅,再切到公路。同一辆车,背景和灯光分开变。
  3. 选试驾模式,确认人坐在方向盘后面,拖拽时人还在座位上。再切到夜晚前脸,看头灯光池。
  4. 换成奔驰 G63,只开前门;换成牧马人,打开尾门,看备胎跟着侧开。
  5. 回到 SU7 Ultra,在默认车漆和熔岩红之间切换,深色拉花还在。

分享链接形如:

?model=su7-ultra&paint=factory&camera=overview&mode=studio&light=day


延伸阅读

相关推荐
老男孩游戏时光1 小时前
Electron 桌面应用开发入门,前端也能写客户端
前端
默_笙1 小时前
🚊 别再让 AI 的记忆写两遍了:PostgreSQL 把关系查询和语义检索装进一张表
前端·javascript
进击的明明1 小时前
TypeScript速通笔记(下)
前端·面试·typescript
lzhdim1 小时前
C#不为人知的10个魔法特性:资深开发者也会震惊的底层奥秘
java·前端·javascript·算法·c#
zach2 小时前
前端落地实战:Next.js 项目 Nginx + PM2 生产部署全流程(附踩坑总结)
前端·pm2·next.js
overmind2 小时前
oeasy教h5前端001 修改的乐趣_直接修改在线网页
前端
只睡四小时3 小时前
JS 手写 D-pad 空间导航:电视端焦点引擎实战
android·开发语言·前端·javascript·ecmascript·hls·空间导航
恋猫de小郭3 小时前
Android 原生的 Compose A2UI 也来了,你还抱着 XML 养老吗?
android·前端·flutter
庄园特聘拆椅狂魔3 小时前
从连连看到粒子消散——前端小游戏与视觉特效的技术选型
前端