Appearance
Three.js 沙盘:影像配准、体块生成与性能优化
个人技术实验记录。整套流程都在本地跑,模型与截图均为自制。
把一个场地的建筑体块放到浏览器里实时看,听起来就是"加载个 glb 而已"。真做起来,坑集中在三处:坐标与配准、阴影、首屏体积。这篇把我踩过的记录和参数选择写下来。
一、轴向约定要先定死
建模工具、导出格式、渲染引擎对坐标轴的理解不一样,不写清楚一定会撞:
- 建模端(Blender):X = 东,Y = -北,Z = 上
- 导出 glTF 后:X = 东,Z = 北
- 渲染端(Three.js):Y 是上
所以脚本里我把局部坐标 (x, y) 映射到场景时,就是 X = x、Z = y,Y 留给高度。这类映射必须集中在一个常量处,别散在各文件。
镜像坑:左手系的模型直接丢给按右手系解释 glTF 的引擎(比如 Cesium),会得到一个镜像的场景——建筑对但左右反。判断方法很简单:拿一个已知不对称的单体,看它出现在哪一侧。解决要么导出时翻转,要么在引擎侧做变换矩阵,我选前者,因为翻转一次比到处补偿可靠。
二、底图配准:别用平面几何去猜朝向
地面贴图最常见的写法是"造一个 PlaneGeometry,然后旋转对齐"。问题在于旋转方向、贴图镜像、四角不等距(影像有轻微旋转角)这三件事会混在一起,出错时很难判断是哪一环。
我的做法是用四角顶点直接构造一个四边形网格,顶点顺序固定 sw → se → ne → nw,UV 固定 (0,0)(1,0)(1,1)(0,1):
ts
const order = ['sw', 'se', 'ne', 'nw']
const positions = order.flatMap((k) => toScene(meta.corners_local_m[k]))
const geo = new THREE.BufferGeometry()
geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geo.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 1], 2))
geo.setIndex([0, 2, 1, 0, 3, 2])
geo.computeVertexNormals()三角形索引 0,2,1 / 0,3,2 是把四边形按同一方向剖开,绕向反了贴图会背面剔除掉。这个写法的好处是旋转角被顶点天然吸收,不用再算旋转。
配准精度靠元数据里的旋转角和像素地面分辨率(我用 0.256 m/px 这一档),对完肉眼检查建筑轮廓和影像边缘是否重合。
关于影像本身:第三方卫星瓦片(Esri、Bing 之类)抓下来做配准参考是学习用途,但把抓下来的影像公开挂在网站上,授权和地理位置敏感性两方面都过不去。所以最终发布版本里不带卫星底图,只放体块 + 参考网格;底图逻辑保留在代码里,本地调试用。
三、阴影:默认参数在大尺度场景全是坑
平行光阴影的默认阴影相机范围是给"一个房间"用的,场地尺度几百米,不贴合就会出现两种病:阴影糊成一团,或者边缘出现条纹/痤疮。
ts
const span = Math.max(size.x, size.z) * 0.9
sun.shadow.camera.left = -span
sun.shadow.camera.right = span
sun.shadow.camera.top = span
sun.shadow.camera.bottom = -span
sun.shadow.camera.near = 1
sun.shadow.camera.far = span * 6
sun.shadow.camera.updateProjectionMatrix()注意最后那个 updateProjectionMatrix(),改完不刷新投影矩阵等于白改(我在这儿卡过一次,画面毫无变化)。
参数选择:
| 参数 | 取值 | 原因 |
|---|---|---|
shadow.mapSize | 4096 | 移动端再大扛不住,2048 边缘有可见锯齿 |
shadow.bias | -0.0025 | 消除自遮挡条纹 |
shadow.normalBias | 2.5 | 大尺度下抵消阴影痤疮,比 bias 更稳 |
shadow.radius | 3 | 轻微柔化,再大就脏 |
toneMapping | ACES Filmic | 高光暗部都不容易死掉 |
toneMappingExposure | 1.15 | 灰模偏暗,微提一档 |
底图材质要设 toneMapped = false,否则影像被色调映射压暗,跟模型对不上颜色。
四、模型居中与相机自适应
模型原点不在中心时,旋转起来像甩鞭子。加载完统一减掉包围盒中心,再按尺寸推相机:
ts
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
model.position.sub(center)
const radius = size.length() / 2
camera.position.set(radius * 0.9, radius * 0.75, radius * 0.9)
camera.near = radius / 100
camera.far = radius * 40
controls.maxDistance = radius * 8
controls.minDistance = radius * 0.15near 太大会有穿模裁切,太小会精度不足产生 z-fighting,按半径的百分比分档最省事。
五、HUD:把统计信息亮出来
查看器左下角我放了几行动态统计:模型尺寸、三角面数、底图状态、操作提示。目的不是好看,而是调参数时能立刻看到代价:
ts
let tris = 0
model.traverse((o) => {
if (!o.isMesh) return
const g = o.geometry
tris += (g.index ? g.index.count : g.attributes.position.count) / 3
})有索引几何要用 index.count,不然面数算成三倍,这是我一开始统计对不上的原因。
六、体积与首屏
- 体块模型(40 多栋)压到 1 MB 以内,靠的是不做立面细节、贴图在建模端程序化生成后打包进 glb,不引外部素材。
- 渲染器
setPixelRatio(Math.min(devicePixelRatio, 2)),4 倍 DPR 的机型上帧率掉得比画质提升亏得多。 - 组件卸载必须清理:
cancelAnimationFrame+controls.dispose()+renderer.dispose(),否则单页应用里来回切页面会累积多个渲染循环。
七、还没做的
- 没有 LOD,场地再大一档必须分块加载
- 立面细节(窗带、卷帘门)只在建模端做了简化,Web 端还没验证效果
- 没有做拾取和单体高亮