如果你觉得开发一块数字乡村大屏,就是“摆几个图表,中间塞个模型”,那这个项目大概会让你重新认识“塞”这个动词。
模型塞进去了,树像一排西兰花;树改漂亮了,路蹭到了邻居家的院墙;河水终于动了,暂停按钮按下去,浏览器还在卖力加班。
欢迎来到青溪村。一座由 Blender、Vue 和 Three.js 共同施工的村庄,也是一次从“画面出来了”走到“真的能交付”的开发练习。
图1|青溪村大屏全貌:可操作的三维村庄与统计卡片。页面数据为固定模拟数据。
这篇文章聊七件事:怎样把村庄做自然,怎样让三维和业务对上号,怎样避免“截图很好看,一操作就露馅”,以及怎样证明性能优化真的有用。
01|先盖五栋房,别急着承包整个村
项目的目标很明确:以可交互的三维村庄为中心,展示人口、产业、便民服务和环境问题。白墙灰瓦、河道石桥、农田林带,要有插画式航拍的自然层次。
技术分工也不复杂:Vue + TypeScript 负责界面和状态,Three.js 负责模型与交互,ECharts 负责服务趋势,Mock.js 提供异步演示快照,Blender 负责资产制作。单页状态用 Vue 自带能力管理,没有提前搭一座比村庄还大的应用框架。
真正麻烦的是,“自然”没有一个 npm 安装命令。
基础样板已经能加载模型、选择建筑、切换镜头,但画面距离预期的精细效果还很远。规则房屋排列、球状树冠、平面地形和简单材质,距离参考插画还有差距。
这时最容易走的弯路,是继续铺满全村细节,期待数量产生美感。结果往往只是得到更多、排列更整齐的西兰花。
后续采用了更可控的办法:把中央河道、前景石桥和五栋民居做成小样,先解决局部,再扩展全景。
树冠改成交错的透明裁切叶簇,让不同视角都有体积;树下补灌木和接触暗部,连接树根与地面;河岸从笔直边界改成缓坡;白墙、屋瓦、石面增加颜色和法线细节;庭院铺地改为错缝石板,支路接到真实院门。
图2|全场景扩展阶段:左为参考插画,中为扩展前,右为扩展后。中、右采用相同浏览器相机;参考图没有相机参数,仅作近似视觉对照。
这里有两条特别实用的经验。
第一,前后对比必须固定镜头。 相机一挪,光照一换,任何优化都可能看起来像“史诗升级”。这个项目记录了实际相机参数,还校验浏览器加载 GLB 的 SHA256,确认比较的是指定资产,避免旧构建悄悄来参加新模型的毕业答辩。
第二,技术通过和视觉通过要分别判断。小样阶段曾出现模型单元、浏览器交互全部通过,但参考图视觉门槛仍未通过的情况。于是继续打磨局部效果,再扩展到全村,没有让“测试全绿”替审美签字。
可带走的办法:先选一个包含主要难点的局部,固定镜头、资产和评审标准,再决定是否批量推广。 小样要能暴露问题,不能只挑最好看的角落。
02|模型可以精修,业务身份证别跟着装修
村庄最终有32栋建筑,业务演示包含四个村组、八块田、六个服务点和24条环境问题。
数字不算多,但三维模型、统计卡片、列表和详情必须指向同一个对象。否则鼠标点的是民居,详情打开的却是隔壁服务中心,数字乡村就会变成数字串门。
项目把几个边界固定下来:
- 建筑用稳定 ID 关联,建筑父节点的
userData.buildingId对应元数据编号;点到屋顶或墙面子网格后,沿父节点查找。 - 标签有独立锚点,读取世界坐标后投影;不拿整栋建筑包围盒中心充当万能位置。
- Blender 到 Web 的坐标转换只发生在边界。GLB 和元数据已经是 Y-up、单位米,前端不再转换一次。
- 模型精修保留建筑几何、位置、ID、所属村组、锚点与业务田块几何,装修和业务关联各守各的边界。
为什么强调包围盒?因为测试里真遇到过:合并院落后,整个建筑包围盒中心落在空庭院。自动化往那里一点击,自然点不到房子。修复的是测试投影辅助点,改用建筑主体内的点,并补上院落外扩的回归断言。
这类问题特别会伪装:看起来像拾取坏了,实际是测试压根没点到目标。
业务数据也有自己的“户籍”。一个异步 XHR 快照覆盖统计与空间明细,交互不反复请求;面积从田块明细汇总,问题状态从问题记录计数。模型里的住户占位字段不拿来充当952户业务统计,示意田块的几何面积也不换算成真实亩数。
同样重要的是失败隔离:某个对象坐标坏了,禁用它的定位和高亮,业务详情仍能看;GLB 加载失败,有效的统计、列表与筛选继续可用。
三维负责告诉你对象在哪里,业务数据负责告诉你对象是什么。两个问题有关联,但不能互相冒充答案。
图3|田块专题:突出当前类别,保留其他田块作为背景;两侧统计保持全村口径。
03|大屏缩放:画面可以变小,坐标不能各说各话
项目以1920×1080为逻辑画布,其他尺寸等比缩放、居中留边。核心计算只有几行:
const scale = Math.min(width / 1920, height / 1080);
return {
scale,
left: (width - 1920 * scale) / 2,
top: (height - 1080 * scale) / 2
};
公式简单,后面的坐标关系才是主菜。
页面有逻辑尺寸,鼠标事件给的是实际屏幕坐标,Three.js 拾取还要使用归一化坐标。三者混用,就会出现一种很玄学的现象:鼠标明明指着这栋房子,系统却觉得你在敲隔壁的门。
拾取要从真实 Canvas 的屏幕边界计算。归一化公式是:
ndcX = 2 × (clientX − rect.left) / rect.width − 1
ndcY = 1 − 2 × (clientY − rect.top) / rect.height
这里的 rect 来自 Canvas 的 getBoundingClientRect()。标签投影则转回逻辑容器尺寸,让外层 CSS 缩放一起生效,不能把屏幕像素直接塞进逻辑画布。
后来场景从中央窗口扩成全屏背景,又多了一层考验:Canvas 负责整页投影和拾取,标签只能显示在中间可操作区域。否则它会热情地跑到统计卡片下面,当一个谁也看不到的按钮。
输入分层也要明确:装饰渐隐和空白覆盖层使用 pointer-events: none,卡片、按钮、详情接收自己的事件。浏览器测试实际拖动背景、操作卡片、滚轮缩放,验证输入落到了正确的地方。
这里还踩过一个等待时机的坑:旧测试在俯视相机真正就绪前就读取投影点,随后点击的坐标已经过期。修复后等待实际相机目标,再计算和点击。
位置相关的测试,要等待位置真的稳定。一个按钮点击完成,不代表相机动画已经完成。
等比缩放也有适用范围:它服务于这块固定布局的大屏。窄窗口能缩小展示,不代表已经做完手机阅读体验。
04|大屏最怕的不是信息少,是每个角落都想当主角
开发一开始很容易舍不得:图表想留,完整列表想留,详情也想留。几块面板越摆越满,村庄逐渐沦为“弹窗之间露出来的一点背景”。
后续的信息组织做了收敛:首页四张卡片提供概览和入口;详细记录进入中央分页列表;选择对象后隐藏来源列表,显示唯一详情气泡;返回时恢复原来的页码、筛选和焦点。
图4|交互迭代中的田块详情:隐藏来源列表,保留详情气泡与返回入口。
这些看起来像小细节,其实决定用户要不要重新找路。
从第3页打开详情,返回却回第1页;关闭详情后焦点丢失,键盘用户得从头 Tab 一圈;复位镜头顺便清掉筛选——每一次都像导航在说:“刚才的不算,咱们重新来。”
项目把行为写得很具体:复位只恢复相机,关闭详情取消选择并恢复入口焦点,筛选排除当前对象时清除它的详情。图表、卡片、列表和场景入口共享唯一选择,避免各自维护一套“当前对象”。
另一个容易偷懒的地方是“无滚动”。加一句 overflow: hidden,滚动条确实没了,内容也可能跟着没了。
这里的验收检查实际 scrollHeight/scrollWidth 与 clientHeight/clientWidth,还检查子元素边界。长说明有完整文字入口和分段分页,问题时间线也能逐页翻阅;测试实际走完全部对象,确认内容可达。
减少首页信息密度,必须同时保留完整内容的访问路径。 一个清爽的大屏,不该靠把麻烦内容藏起来实现。
05|云要飘,水要流,但别顺手造一套水文系统
村庄静态画面收敛后,下一步是让它有一点呼吸感。
云海复用三层曲面和共享时间 uniform,让噪声纹理沿世界方向缓慢漂移。世界范围内的遮罩保持固定,保证中央河道依然清楚;不同层采用不同速度和相位,增加层次。
河水则复用既有颜色与法线贴图。为了让弯曲河道的水纹顺流,先从现有单河道横截面提取中心,再累计相邻中心的距离,生成沿河的米制 UV。约24米重复一周期,以0.35米/秒推进贴图采样偏移。
动的是纹理,岸线、顶点水色和桥影保持静止。没有为这次动效新增网格、贴图文件或渲染目标。
图5|河道、石桥和院落近景。静态截图展示水面材质、叶簇与岸边层次。
比“能动”更重要的是“什么时候不动”:用户暂停、系统偏好减少动态效果、页面隐藏、模型未就绪或 WebGL 上下文丢失时,冻结相位;恢复时重置时间基准,不追赶后台积攒的时间。单帧最多推进0.1秒,防止一次长卡顿把云送到天边。
时间保留在普通对象中,由唯一绘制循环推进,不每帧往 Vue 响应式状态写一遍。
这套实现有明确边界:当前沿河 UV 针对已有单河道,分叉河网需要资产提供流向属性;替换水面不适用时回退静态显示。它满足插画式环境动画的需求,没有扩展成真实水文模拟。
动效测试也没有只看“时间变量增加了”。浏览器从真实 WebGL 河面和云雾区域采样像素,比较0秒与12秒画面;同相位重复绘制还要一致。暂停后相机依然能转,专题依然能切。
动画验收得看到画面变化,也得确认暂停没有把其他操作一起暂停。
06|性能优化第一刀:让浏览器少干白活
最有戏剧性的一组性能数据,来自优化前的3秒生产页面采样:播放详情和暂停详情,居然都是4163次 DOM 更新、905次布局计算。暂停后还在绘制。
河水休息了,浏览器没休息。堪称数字乡村里的劳模。
第一步优化处理了这些重复劳动:动效播放时逐帧绘制;暂停或系统减少动态效果时,只有相机、选择、专题、尺寸或加载恢复等输入变化才重绘;隐藏页面跳过绘制与布局。标签和气泡也只在位置、内容尺寸或 DOM 节点等输入变化后更新。
缓存尤其不能只记“对象 ID 没变”。同一个对象翻页后气泡可能变高,节点也可能被替换。漏掉这些输入,性能看起来好了,详情却会留在错误的位置。
优化后,同机3秒采样中,播放详情和暂停详情的稳态 DOM 写入与布局次数都降为0;暂停详情的主线程任务耗时从0.4459秒降到0.0730秒,约减少83.6%。这是有限采样结果,收益会随设备、状态和调度变化。
但第一轮4K/DPR2测试仍没通过:约37.35 FPS,超过100ms的长帧出现7次。平均帧率达到30,并不能抵消明显卡顿。
于是第二刀落到云雾着色器:已经确定透明的片元,尽早跳过昂贵的噪声计算。
原逻辑先算噪声,最后发现透明再丢弃。新逻辑先利用噪声和薄雾的最大贡献估计透明度上界;如果上界都低于既有阈值,就直接 discard。关键判断如下:
if ((rim + grove * .18) * edge * strength < .005) discard;
几何、贴图、镜头、云层速度和 DPR 上限都保留。同一 GPU、镜头和相位下的四组整帧像素对比,只有48~89个颜色通道相差1档8位值,其余一致,没有 GL 错误。这给“减少计算仍保留画面”提供了证据。
还有一个很容易被漂亮数字骗过的问题:requestAnimationFrame 回调每秒执行60次,并不代表 WebGL 真画了60帧。最终性能用例同时检查回调节奏与实际绘制帧率,避免把空转统计成流畅。
2026年10月12日,三组各60秒测试的最终测量如下:
| 浏览器视口 / DPR | 实际绘制 FPS | P95帧间隔 | 超过100ms长帧 |
|---|---|---|---|
| 1920×1080 / 1 | 60.02 | 16.7ms | 0 |
| 3840×2160 / 1 | 60.01 | 16.7ms | 0 |
| 3840×2160 / 2 | 59.64 | 16.8ms | 0 |
环境为 Apple M4 Max、macOS 26.6.2、Chromium 156、本机回环网络。DPR 上限为1.5,因此4K/DPR2场景缓冲实际为5760×3240;这是该机器上的浏览器视口测试,没有接入外部4K实体显示器。P95是帧间隔统计,不是 GPU 单帧耗时。
接着是完整30分钟持续交互:实际运行约1800秒,完成1603轮交互,29个完整分钟帧窗全部达标;上下文丢失为0,资源计数前后一致,精确 JS 堆首末七分钟中位数增长7.90MiB,低于32MiB门槛。
图6|30分钟持续交互测试结束时的页面。性能与资源变化由测试过程持续采样。
这些结果说明当前环境下达到了冻结的预算。换机器、换浏览器、换网络,需要重新测;30分钟通过也不能推出无限运行无泄漏。
先剖析,再消除重复工作,最后按原标准复测。 保留首次失败结果,比把阈值改宽更有技术价值。
07|和 AI 一起开发,最该写清楚的是“怎样才算完成”
项目根目录的 AGENTS.md 把必读文档、修改边界和验收门槛写成了明确入口。设计、接口、交互、测试及模型维护放在 docs/,每个独立功能有自己的验收记录。
这给 AI 协作提供了一套可以执行的上下文:先读现有实现和调用方;保留用户改动与手工模型;新增逻辑补自动化单元测试;功能在真实浏览器里操作;提交前格式化和独立类型检查;验收通过后按功能提交。
比“再漂亮一点”更好执行的目标,是“固定五栋建筑坐标,调整道路与庭院,路径不得穿墙,叶簇要有体积,旧资产要保留”。比“测试一下”更好执行的要求,是“选择对象、旋转、放大、真实点击主体、核对 ID、Escape 关闭,再复位”。
这套流程确实抓到了细小却真实的问题:全路径中心线检查发现民居14的一段支路轻擦院墙,避让从稀疏射线采样改成实际墙体最近距离;工具栏变大后挡住了旧拾取测试点,测试改为先确认命中层是 Canvas,再点击无遮挡建筑。
测试失败时,先分清产品缺陷、测试前提过期和环境问题。 修复断言的前提可以,削弱仍然有效的业务标准不行。
这一轮性能优化最终通过了141项单元测试、70项浏览器测试,以及完整30分钟持续测试。数字本身不是质量勋章,覆盖到的行为才是:加载、失败、空数据、长文字、焦点恢复、尺寸变化、资源释放和上下文恢复,都要有落点。
“构建成功”只能证明项目能打包;“截图好看”只能证明这个时刻好看。用户真正会做的,是拖两下、点几次、关个详情、换个窗口,然后继续用。
写到这里,青溪村带来的最大心得已经很具体了:模型变漂亮时,业务关系还得对;界面变清爽时,内容还得看得到;渲染变省时,画面与交互还得保持完整。
每一次独立验收,都是把一个“看起来可以”变成一个有证据的“这里可以”。
下次再听到“就是几个图表加一个模型”,我大概会先打开浏览器,把窗口缩小,再点一下那栋房子。
毕竟,村可以慢慢盖,隔壁家的详情不能随便开。
效果预览:vtj.pro/village/dis…
→ 需要源码可私信