不写模型文件,用代码「捏」出 3D 世界:Vue3 + Three.js 程序化资产生成实战
在构建3D页面时,传统的路径是先用Blender建模,再导出glTF文件,最后用Three.js加载。这条路当然走得通,但实际操作起来,总会遇到几个绕不开的痛点。
比如,想改个尺寸就得重新建模;想要100个变体就得准备100个文件;模型文件动辄几MB,下载体积不小;更别提想用UI滑块联动调整参数有多麻烦了。
那么,有没有一种更灵活、更轻量的方式?答案是肯定的,那就是程序化生成。
程序化生成的核心思想,是把建模的逻辑用代码写出来。这样一来,改尺寸就变成了改一个数字,几何体可以实时重建;一套参数数据和生成函数,就能覆盖无限种变体;纯JS计算,代码体积只有KB级别;参数本身就是API,天然就能和表单控件联动。
下面要介绍的这个项目,正是基于这个思路。它已经实现了16种常见物体(如杯子、桌子、衣柜、酒瓶等),并支持通过滑块实时调参、随机生成变体,以及场景组合展示。接下来,我们就从架构到源码,完整地拆解一遍“如何用代码捏出3D模型”。
一、整体架构:清晰的三层设计
整个项目的架构可以清晰地分为三层:视图层、场景管理层和资产层。
视图层由Vue组件构成,例如AllInOne.vue或Demo.vue,负责资产选择、参数面板的渲染以及滑块交互事件的响应。
场景管理层的核心是ThreeScene.vue组件和useThreeScene.js这个Composable。它封装了Three.js的Scene、Camera、Renderer和OrbitControls的初始化与生命周期管理,并对外提供了添加、移除物体等简洁的API。
资产层是项目的核心,位于src/assets/目录下。这里存放着每个物体的定义文件(如mug.js, table.js),一个统一的分类注册表(catalog.js),以及一些关键的底层工具,比如资产基类、自定义几何体(assets-utils.js)和用于UV图集打包的算法(bin-packing.js)。
当用户在界面上拖动滑块调参时,数据流是这样的:参数变化触发Vue组件的更新,组件调用当前资产实例的generate()方法并传入新参数,该方法会基于新参数重新计算并生成几何体,最后Three.js渲染器将更新后的模型呈现在画布上。整个过程是实时的。
二、项目目录结构一览
为了更直观地理解,我们先看看项目的核心目录结构:
src/
├── assets/ # 程序化资产核心
│ ├── assets-utils.js # Asset 基类、LatheUVGeometry、RoundedBoxGeometry...
│ ├── bin-packing.js # UV 矩形打包算法
│ ├── mug.js / plate.js ... # 每个物体一个模块
│ ├── procedural-kit.js # 【扩展】工厂函数,快速批量造物体
│ ├── catalog.js # 【扩展】分类目录(家具/兵器/交通...)
│ └── index.js # 统一导出
├── components/
│ └── ThreeScene.vue # 3D 画布封装
├── composables/
│ └── useThreeScene.js # Three.js 生命周期管理
└── views/
├── AllInOne.vue # 全场景 / 单物体展示 + 参数面板
└── Demo.vue # 分卡片 Demo
三、核心设计:每个物体都是一个Class
为了实现统一的创建、管理和参数化,项目中所有可生成的3D资产都继承自同一个基类——Asset。这个类本质上是一个THREE.Group,但赋予了资产参数化与随机化的能力。
基类提供了两个非常实用的静态方法:defaults用于从paramData中提取所有参数的默认值;random则能根据每个参数定义的min、max或chance(概率),生成一套合法的随机参数。
3.1 标准资产模板:四件套缺一不可
每个具体的资产文件,比如一个杯子或一张桌子,都遵循一套标准的模板,可以概括为“四件套”:
- 静态属性
name:资产的显示名称。 - 静态属性
paramData:定义所有可调参数的元数据(Schema),这是驱动UI面板的“契约”。 - 构造函数
constructor:接收参数并立即调用generate方法创建几何体。 - 核心方法
generate:根据传入的参数,程序化地构建几何体和材质,并将其添加到Group中。 - 清理方法
dispose(可选但重要):用于释放旧的几何体GPU内存,防止泄漏。
3.2 paramData:连接几何逻辑与UI面板的契约
paramData是这个设计中最精妙的部分。它不仅仅是一份参数说明,更直接驱动了前端的参数面板自动生成。
例如,一个盘子的厚度参数可能这样定义:
plateHeight: {
default: 1.6,
type: 'cm', // 单位:厘米(内部会用工具函数转为Three.js的米制单位)
min: 0.5,
max: 5,
folder: 'Plate', // 在UI面板中,这个参数会被归到“Plate”分组下
name: 'Height' // 在UI面板中显示为“Height”
}
这样的设计有几个关键要点:
- 使用
cm、deg、%等业务单位,而不是直接暴露Three.js的内部坐标,对开发者更友好。 folder和name属性让UI面板可以自动分组和显示,无需手动编写表单代码。min和max不仅限制了UI滑块的取值范围,更重要的是保证了随机生成或用户输入时,不会产生非法的几何数据。
四、三种几何构建方式,由简到难
根据物体的形状复杂度,项目中主要采用了三种几何构建方式。
4.1 旋转体 (Lathe) —— 适合杯、盘、瓶
原理:在XY平面上定义一组轮廓点,然后让这组点绕Y轴旋转一圈,从而生成一个旋转体。这非常适合制造具有轴对称特性的物体。
以创建一个盘子为例,首先在generate方法中根据参数计算出轮廓点数组,然后使用扩展版的LatheUVGeometry(它在Three.js原生LatheGeometry基础上完善了UV映射)来生成几何体。最后,为几何体赋予材质并添加到场景中。
适用场景:所有具有旋转对称性的物体,如杯子、盘子、酒瓶、花瓶、盾牌等。
4.2 圆角盒子 (RoundedBox) —— 适合箱子、设备外壳
对于现代设计中常见的带圆角的方正物体,直接使用项目封装的RoundedBoxGeometry。它可以通过参数一次性指定长宽高、圆角大小、细分段数,甚至可以选择哪些面需要渲染、哪些边需要倒角,功能非常灵活。
适用场景:音箱、显示器外壳、收纳盒,以及任何“方方正正但有圆角”的物体。
4.3 挤出 + UV 图集 —— 适合复杂家具
像桌子、椅子、衣柜这类家具,形状往往更复杂,可能包含异形截面和曲线路径。这时就需要用到“挤出”技术。
构建过程分为三步:
- 用
RoundedShape定义截面形状,比如一个桌腿的横截面轮廓。 - 用贝塞尔曲线(
CubicBezierCurve3)定义这条桌腿在空间中的生长路径。 - 使用
SmoothExtrudeGeometry,让截面形状沿着定义好的路径“挤出”,从而形成三维实体。
为什么要用UV图集打包?
复杂家具由多个部件组成,每个部件都有自己的纹理坐标(UV)矩形。如果每个部件单独用一张贴图,会造成巨大的纹理浪费和性能开销。bin-packing.js文件实现了MAXRECTS-BSSF-BNF算法,它能高效地将所有大小不一的UV矩形“打包”进同一张大的纹理贴图(图集)中,极大提升了纹理利用率。这是本项目与普通Three.js Demo在技术深度上的一个显著区别。
五、Three.js 场景的现代化封装
5.1 useThreeScene —— 用Composable管理生命周期
为了将Three.js的初始化、渲染循环和资源管理与Vue3的响应式系统优雅结合,项目创建了useThreeScene这个Composable。它接收一个容器DOM引用,在其内部创建并管理Scene、Camera、Renderer和OrbitControls。它提供了addObject、removeObject、clearScene等简洁API,并确保了在组件卸载时能正确销毁渲染器和释放控制。
5.2 ThreeScene.vue —— 对外暴露干净API的组件
ThreeScene.vue组件是一个薄薄的包装层。它在内部调用useThreeScene,并在合适的生命周期(onMounted/onUnmounted)触发初始化和清理。最关键的一步是,它通过Vue 3的defineExpose将场景管理器的API暴露出去。
这样,父组件就可以通过模板引用(ref)直接操作3D场景:
// 在父组件中
const sceneRef = ref(null);
// 创建一个杯子
const mug = new Assets.Mug(Assets.Mug.defaults);
// 调整大小并加入场景
mug.scale.setScalar(5);
sceneRef.value.addObject(mug);
// 实时修改参数,重建模型
mug.generate({ ...newParams });
六、Vue参数面板:实现滑块拖动,模型实时变化
实现交互式调整的关键思路是:不要销毁和重建整个场景或物体,只调用物体自身的generate()方法,在原地替换几何体。
在AllInOne.vue组件中,核心逻辑是维护一个当前参数的响应式对象,以及一个对当前场景中物体的引用。当任何参数滑块被拖动时,会触发onParamInput函数,该函数更新参数并计划一次模型刷新。
这里用到了一个重要的优化技巧:使用requestAnimationFrame进行节流。因为滑块拖动会高频触发input事件,如果每次事件都立即重建几何体,会导致界面卡顿。通过节流,可以确保在每一帧动画中,最多只执行一次generate()调用,从而在保持响应性的同时,极大减轻CPU/GPU的压力。
完整的交互链路如下:
- 用户从下拉框选择一种资产(如“杯子”)。
- 前端根据该资产的类,用默认参数创建一个实例,并添加到Three.js场景中。
- 同时,前端读取该资产类的
paramData,动态渲染出对应的滑块、开关等参数控件。 - 用户拖动滑块,参数更新,触发
generate(newParams),模型在场景中实时变化。 - 用户点击“随机生成”按钮,调用
AssetClass.random()生成一套随机参数,并应用,一键获得全新造型。
七、模块化扩展:如何从16个物体扩展到70+
当资产数量越来越多时,手动管理每个类会变得困难。项目引入了“工厂+目录”两层抽象来解决这个问题。
7.1 procedural-kit.js —— 资产工厂
这是一个用于快速创建新资产类的工厂函数createAsset。你只需要提供资产名称、参数定义(paramData)和一个构建函数(build),它就会返回一个符合标准的、继承自Asset基类的新类。这个工厂还自动处理了旧几何体清理、物体自动“落地”(使其底部站在y=0平面)等通用逻辑,让开发者只需关注最核心的形状构建代码。
7.2 实战:用30行代码造一把“剑”
利用工厂函数,创建一个新的资产变得非常简洁。例如,定义一把剑只需要约30行代码:指定剑刃长度、宽度、手柄长度等参数,然后在build函数中,用基本的BoxGeometry(剑刃)和CylinderGeometry(剑柄)组合起来即可。新增一个物体,几乎变成了“复制模板 + 修改build函数”的填空题,效率极高。
7.3 catalog.js —— 分类注册表
当资产数量庞大后,需要一个目录来分类管理。catalog.js导出一个分类数组,例如“家具”、“兵器”、“交通工具”等。每个分类下包含其拥有的资产列表。Vue的下拉选择框可以直接绑定到这个分类数据,自动生成分组选项,用户体验更好。
一个推荐的分类扩展体系可以是:家具(挤出+圆角盒)、兵器(基础几何体组合)、交通工具(组合图元)、日用品(旋转体)、电子设备(薄盒子)、建筑(基础几何体)、自然(球体+噪声)等。
八、从零实现一个“花瓶”完整示例
我们以创建一个“花瓶”为例,走一遍完整流程:
Step 1: 定义参数。 在对应的分类文件(如daily.js)中,使用createAsset工厂函数,定义花瓶的高度、瓶身大小、瓶颈大小、鼓起程度等参数。
Step 2: 编写build逻辑。 在build函数中,将参数转换为实际尺寸,然后使用LatheUVGeometry,通过定义几组关键的轮廓点(底部、瓶身最宽处、肩部、瓶口)来生成花瓶的旋转体几何体。
Step 3: 注册到目录。 在catalog.js的“日用品”分类中,添加一行,将刚创建的花瓶类注册进去。
Step 4: 在页面中使用。 在Vue组件中,从目录中按索引或ID获取到花瓶的类定义,用其默认参数创建实例,并调用场景的addObject方法加入渲染。
完成以上四步,一个可交互、参数可调的花瓶就诞生了。打开页面,拖动高度或鼓起程度的滑块,就能看到花瓶形状实时变化。
九、性能优化与实战踩坑指南
在开发过程中,以下几点对于保证应用性能和稳定性至关重要:
9.1 必须dispose旧几何体。 在资产的generate方法开头或dispose方法中,务必调用旧几何体的.dispose()方法。否则,每次参数变化都会在GPU中残留旧的几何数据,导致显存泄漏,页面会越来越卡。
9.2 解决Vite构建相关报错。 如果项目使用了Element Plus等UI库,且Vite配置了optimizeDeps.noDiscovery: truevite.config.js的optimizeDeps.include中显式包含'dayjs'等依赖。
9.3 注意大物体的相机距离。 对于建筑等大型物体,其尺寸可能达到数米。需要相应调整OrbitControls.maxDistance,或者在将物体加入场景前,统一进行缩放(instance.scale.setScalar(5)),以确保其在相机视野内。
9.4 实现“可控随机”。 生成随机变体时,务必使用资产类提供的static random()方法,而不是直接用Math.random()。前者会严格遵守paramData中定义的min、max、chance等规则,确保生成的参数总是合法的,不会产生破碎的模型。
十、未来可以延伸的方向
这套程序化生成的架构具有很好的扩展性,天然适合向以下几个方向演进:
- 大屏3D编辑器:左侧是资产库(已实现的catalog),中间是3D画布,右侧是属性面板(已实现的参数控件),形成一个完整的编辑环境。
- 低代码3D场景搭建:将catalog视为3D组件库,用户通过拖拽和参数配置就能快速搭建出复杂的3D场景。
- AI生成参数:让大语言模型(LLM)理解物体描述,输出符合
paramDataSchema的JSON参数,调用generate(json)即可直接生成对应模型。 - 导出glTF:利用Three.js的
GLTFExporter,将程序化生成的动态模型“烘焙”成标准的静态glTF文件,用于其他平台。 - 游戏关卡道具:利用
random()方法,每次游戏关卡加载时,生成外形略有不同的道具(如不同形状的岩石、箱子),增加游戏内容的多样性。
十一、总结
回顾整个项目,其成功的关键在于清晰的层级划分和解耦:
| 层级 | 职责 | 关键文件 |
|---|---|---|
| 基类 | 参数默认值 / 随机 / Group 容器 | assets-utils.js |
| 资产 | 描述「怎么造」 | mug.js, table.js... |
| 几何 | Lathe / RoundedBox / Extrude | assets-utils.js |
| UV | 图集打包 | bin-packing.js |
| 扩展 | 批量造物体 | procedural-kit.js |
| 目录 | 分类管理 | catalog.js |
| 视图 | 3D 展示 + 调参 | AllInOne.vue |
一句话总结: 正是这种“资产定义、几何生成、UI交互”三者解耦的设计,使得项目能够从一个简单的杯子示例,轻松扩展到管理数十上百种各不相同的3D物体,而整个架构依然保持清晰和可维护。
附录:如何本地运行与探索
如果你想亲身体验或基于此项目进行二次开发,可以按照以下步骤在本地运行:
git clone [项目仓库地址]
cd three.js-3d-assets
npm install
npm run dev
项目启动后,通常可以通过以下路由访问不同功能页面:
| 路由 | 说明 |
|---|---|
/ |
首页 — 资产卡片概览 |
/demo |
Demo — 分卡片展示 |
/all-in-one |
全场景 — 多物体同屏 + 参数面板 |
通过动手实践,你将更深刻地理解如何用代码“捏”出一个参数化、可交互的3D世界。
