不写模型文件,用代码「捏」出 3D 世界:Vue3 + Three.js 程序化资产生成实战
本文详解如何用 参数驱动 + 几何体组合 的方式,在浏览器里实时生成可交互的 3D 物体。
技术栈:Vue 3 · Vite · Three.js · Element Plus
适合:前端工程师、WebGL 入门者、想做大屏/编辑器/低代码 3D 的同学
🔗 本文对应开源仓库:qdcxj/three.js-3d-assets
📦 基于原项目:boytchev/3d-assets 的 Vue 3 二次开发版本
效果预览
Demo 展示页(/demo):16 种程序化 3D 资产分卡片渲染,每个模型支持「重新生成」随机变体。

本地运行:
npm run dev→ 访问http://localhost:3000/demo掘金发布时若图片不显示,可使用 GitHub 图床地址:
https://raw.githubusercontent.com/qdcxj/three.js-3d-assets/main/docs/image.png
前言:为什么不用 .glb?
做 3D 页面时,常见路径是:Blender 建模 → 导出 glTF → Three.js 加载。
这条路没问题,但会遇到几个痛点:
| 问题 | 程序化生成的解法 |
|---|---|
| 改尺寸要重新建模 | 改一个数字,几何体实时重建 |
| 100 个变体 = 100 个文件 | 一套 paramData + generate() 覆盖无限变体 |
| 下载体积大 | 纯 JS 计算,KB 级代码 |
| UI 滑块无法联动 | 参数即 API,天然对接表单 |
程序化生成(Procedural Generation) 的核心思想是:
物体 = f(参数)
不是加载静态网格,而是用代码描述「怎么造出来」。
本项目(three.js-3d-assets --- 基于 boytchev/3d-assets 的 Vue 化改造)已经实现了 16 种物体(杯子、桌子、衣柜、酒瓶......),并支持 滑块实时调参、随机生成、场景组合展示。
下面从架构到源码,完整讲清楚「怎么实现」。
一、整体架构
text
┌─────────────────────────────────────────────────────────┐
│ Vue 视图层 │
│ AllInOne.vue / Demo.vue │
│ · 下拉选择资产 · 参数面板 · 滑块实时刷新 │
└───────────────────────┬─────────────────────────────────┘
│
┌───────────────────────▼─────────────────────────────────┐
│ ThreeScene.vue + useThreeScene.js │
│ · Scene / Camera / Renderer / OrbitControls │
│ · addObject / removeObject / clearScene │
└───────────────────────┬─────────────────────────────────┘
│
┌───────────────────────▼─────────────────────────────────┐
│ src/assets/ 资产层 │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ mug.js │ │ table.js │ │ catalog.js │ │
│ │ plate.js │ │ wardrobe.js │ │ (分类注册表) │ │
│ └──────┬──────┘ └──────┬───────┘ └───────────────┘ │
│ └────────────────┴──────────────────────────────│
│ │ │
│ assets-utils.js (基类 + 自定义几何体) │
│ bin-packing.js (UV 图集打包) │
└─────────────────────────────────────────────────────────┘
数据流(调参时发生了什么):
二、项目目录说明
text
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
所有资产继承同一个基类 Asset(本质是 THREE.Group):
javascript
// assets-utils.js
class Asset extends Group {
// 从 paramData 自动提取默认值
static get defaults() {
let result = {}
for (const [key, param] of Object.entries(this.paramData)) {
result[key] = param.default
}
return result
}
// 按 min/max/chance 随机生成一套参数
static random() {
let result = {}
for (const [key, param] of Object.entries(this.paramData)) {
if (param.type != Boolean) {
result[key] = random(param.min, param.max, param.prec)
}
if (param.type == Boolean) {
result[key] = Math.random() < param.chance
}
}
return result
}
}
3.1 标准资产模板
每个物体文件都遵循 四件套:
javascript
class Xxx extends ASSETS.Asset {
static name = 'Xxx' // ① 显示名
static paramData = { ... } // ② 参数 Schema
constructor(params) { // ③ 构造时 generate
super()
this.generate(params)
}
generate(params) { ... } // ④ 核心:程序化构建
dispose() { ... } // ⑤ 释放 GPU 几何体内存
}
3.2 paramData:参数就是 UI 的「契约」
paramData 不仅描述几何计算,还直接驱动前端参数面板:
javascript
static paramData = {
plateHeight: {
default: 1.6,
type: 'cm', // 单位:厘米(内部用 cm() 转 Three.js 米制)
min: 0.5,
max: 5,
folder: 'Plate', // UI 分组
name: 'Height' // UI 显示名
},
plateComplexity: {
default: 50,
type: 'n', // 整数(细分段数)
min: 4,
max: 120,
exp: true // 随机生成时用指数分布
},
flat: {
default: false,
type: Boolean,
chance: 0.3 // random() 时 30% 概率为 true
}
}
设计要点:
- 用
cm/deg/%等业务单位,别直接暴露 Three.js 坐标 folder+name让属性面板自动分组,无需手写表单min/max保证滑块不会生成非法几何
四、三种几何构建方式(由简到难)
4.1 旋转体 Lathe ------ 适合杯、盘、瓶
原理: 在 XY 平面画轮廓点,绕 Y 轴旋转一圈。
以 Plate(盘子)为例:
javascript
generate(params) {
this.dispose()
const pH = ASSETS.cm(params.plateHeight)
const pS = ASSETS.cm(params.plateSize)
const pC = Math.floor(params.plateComplexity)
// 轮廓点:[x, y, 圆角半径, uv坐标]
const points = [
[0, 0],
[pS / 2, 0],
[pS / 2, pH],
[pS / 2 - pW, pH],
[0, pW]
]
const geometry = new ASSETS.LatheUVGeometry(points, pC)
const material = ASSETS.defaultMaterial.clone()
this.body = new THREE.Mesh(geometry, material)
this.add(this.body)
}
LatheUVGeometry 在 Three.js 原生 LatheGeometry 基础上扩展了 UV 映射,后续贴图不会乱。
适用: 杯子、盘子、酒瓶、花瓶、盾牌的弧度轮廓......
4.2 圆角盒子 RoundedBox ------ 适合箱子、设备外壳
RoundBox 用 RoundedBoxGeometry 一次生成带倒角的 Box:
javascript
this.box = new ASSETS.RoundedBoxGeometry({
x: params.x, y: params.y, z: params.z,
roundness: params.roundness,
segments: params.roundDetail,
faces: [params.f0, params.f1, ...], // 哪些面要渲染
roundFaces: [params.r0, ...], // 哪些边要倒角
})
this.add(new THREE.Mesh(this.box, material))
适用: 音箱、显示器外壳、收纳盒、任何「方方正正但有圆角」的东西。
4.3 挤出 + UV 图集 ------ 适合复杂家具
Table、Chair、Wardrobe 这类家具有 异形截面 + 曲线路径挤出:
javascript
// 1. 用 RoundedShape 定义截面(桌腿轮廓)
const legProfile = new ASSETS.RoundedShape([
[0, legThickness / 2],
[-legThickness / 2, legThickness / 2, legRoundness, 0.2, , roundDetail],
// ...
])
// 2. 用贝塞尔曲线定义桌腿路径
const curve = new THREE.CubicBezierCurve3(
new THREE.Vector3(-legOffset, top, 0),
new THREE.Vector3(-(legOffset + b), top * (1 - a), 0),
new THREE.Vector3(-legSpread, top * legShape, 0),
new THREE.Vector3(-legSpread, 0, 0)
)
// 3. 沿路径挤出截面
const geom = new ASSETS.SmoothExtrudeGeometry(legProfile, {
extrudePath: curve,
steps: params.legDetail,
caps: [1, 1]
})
为什么要 bin-packing?
复杂家具有多个部件,每个部件 UV 矩形大小不一。bin-packing.js 用 MAXRECTS-BSSF-BNF 算法把所有 UV 矩形打进同一张贴图 atlas,避免纹理浪费:
javascript
import * as BP from './bin-packing.js'
const rects = ASSETS.SmoothExtrudeGeometry.getRectangles(legProfile, legData)
const binPacker = BP.minimalPacking(rects, 1.0)
binPacker.generateUV() // 写回每个几何体的 uvMatrix
这是本项目最「硬核」的部分,也是和普通 Three.js Demo 拉开差距的地方。
五、Three.js 场景封装
5.1 useThreeScene ------ Composable 管理生命周期
javascript
// composables/useThreeScene.js
export function useThreeScene(containerRef) {
function init() {
scene = new THREE.Scene()
camera = new THREE.PerspectiveCamera(35, aspect, 0.1, 100)
renderer = new THREE.WebGLRenderer({ antialias: true })
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
animate()
}
function addObject(object) {
scene.add(object)
objects.push(object)
}
function clearScene() {
objects.forEach(obj => scene.remove(obj))
objects = []
}
return { init, dispose, addObject, removeObject, clearScene,
getScene, getCamera, getRenderer }
}
5.2 ThreeScene.vue ------ 对外暴露 API
vue
<script setup>
const containerRef = ref(null)
const sceneManager = useThreeScene(containerRef)
onMounted(() => sceneManager.init())
onUnmounted(() => sceneManager.dispose())
defineExpose({ ...sceneManager })
</script>
父组件通过 ref 调用:
javascript
const sceneRef = ref(null)
// 创建物体
const mug = new Assets.Mug(Assets.Mug.defaults)
mug.scale.setScalar(5)
sceneRef.value.addObject(mug)
// 实时重建
mug.generate({ ...newParams })
六、Vue 参数面板:滑块拖动实时变模型
关键思路:不要重建整个 Scene,只调用 generate() 原地换几何体。
javascript
// AllInOne.vue 核心逻辑
const currentParams = reactive({})
let currentObject = null
let refreshFrameId = null
function onParamInput(key, value) {
currentParams[key] = typeof value === 'boolean' ? value : parseFloat(value)
scheduleRefresh()
}
function scheduleRefresh() {
if (refreshFrameId) return
refreshFrameId = requestAnimationFrame(() => {
refreshFrameId = null
currentObject?.generate({ ...currentParams })
})
}
为什么用 requestAnimationFrame 节流?
拖动滑块时 input 事件每秒触发几十次,每次都重建几何体会卡。节流到「每帧最多重建一次」,体验丝滑,CPU/GPU 压力也可控。
完整交互链路:
- 下拉框选择资产 →
new AssetClass(defaults)→ 加入场景 - 读取
AssetClass.paramData→ 渲染滑块 / 开关 - 滑块拖动 → 更新
currentParams→generate()→ 模型实时变化 - 点击「随机生成」→
AssetClass.random()→ 一键换造型
七、模块化扩展:从 16 个到 70+ 个物体
当物体数量上来后,建议引入 工厂 + 目录 两层抽象。
7.1 procedural-kit.js ------ 资产工厂
javascript
import * as ASSETS from './assets-utils.js'
export const COMMON_COMPLEXITY = {
segments: { default: 24, type: 'n', min: 8, max: 64, folder: 'Complexity', name: 'Segments' },
flat: { default: false, type: Boolean, chance: 0.3, folder: 'Complexity', name: 'Flat' }
}
export function createAsset({ name, paramData, build }) {
class GeneratedAsset extends ASSETS.Asset {
static name = name
static paramData = paramData
constructor(params) {
super()
this.generate(params)
}
generate(params) {
this.dispose()
this._meshList = []
const add = (mesh) => { this._meshList.push(mesh); this.add(mesh); return mesh }
build({ params, material: makeMaterial(params), add, THREE, ASSETS })
// 自动落底:让物体站在 y=0
const box = new THREE.Box3().setFromObject(this)
if (!box.isEmpty()) this.position.y -= box.min.y
}
dispose() {
this._meshList?.forEach(m => m.geometry?.dispose())
this._meshList = []
this.clear()
}
}
return GeneratedAsset
}
7.2 用 30 行代码造一把「剑」
javascript
// categories/weapons.js
import { createAsset, COMMON_COMPLEXITY, cm, segs } from '../procedural-kit.js'
export const Sword = createAsset({
name: 'Sword',
paramData: {
bladeLength: { default: 70, type: 'cm', min: 40, max: 110, folder: 'Blade', name: 'Length' },
bladeWidth: { default: 5, type: 'cm', min: 3, max: 10, folder: 'Blade', name: 'Width' },
handleLength:{ default: 18, type: 'cm', min: 10, max: 30, folder: 'Handle', name: 'Length' },
...COMMON_COMPLEXITY
},
build({ params: p, material: mat, add, THREE }) {
const bl = cm(p.bladeLength)
const bw = cm(p.bladeWidth)
const hl = cm(p.handleLength)
add(new THREE.Mesh(new THREE.BoxGeometry(bw, bl, cm(0.8)), mat)).position.y = hl + bl / 2
add(new THREE.Mesh(new THREE.CylinderGeometry(cm(1.2), cm(1.2), hl, segs(p)), mat)).position.y = hl / 2
}
})
新增一个物体 = 复制模板 + 改 build 函数,10 分钟一个,非常适合批量铺量。
7.3 catalog.js ------ 分类注册表
javascript
export const assetCategories = [
{
id: 'furniture',
label: '家具',
items: [
{ name: 'Chair 椅子', class: Chair },
{ name: 'Table 桌子', class: Table },
{ name: 'Lamp 台灯', class: Lamp },
// ...
]
},
{
id: 'weapons',
label: '兵器',
items: [
{ name: 'Sword 剑', class: Sword },
{ name: 'Axe 斧', class: Axe },
]
},
// 交通工具 / 日用品 / 建筑 / 自然 ...
]
export const flatAssetList = assetCategories.flatMap(c => c.items)
Vue 下拉框直接用分组:
vue
<el-select v-model="selectedAssetIndex" filterable>
<el-option-group
v-for="category in assetCategories"
:key="category.id"
:label="category.label"
>
<el-option
v-for="asset in category.items"
:key="asset.id"
:label="asset.name"
:value="asset.id"
/>
</el-option-group>
</el-select>
推荐分类体系:
| 分类 | 示例 | 主要几何手段 |
|---|---|---|
| 家具 | 桌/床/沙发/书架 | 挤出 + 圆角盒 |
| 兵器 | 剑/盾/弓/矛 | Box + Cylinder + Lathe |
| 交通工具 | 汽车/火箭/船 | 组合 primitive |
| 日用品 | 杯/盘/瓶/花瓶 | Lathe |
| 电子设备 | 手机/显示器 | 薄 Box |
| 建筑 | 柱/墙/塔/桥 | Box + Cylinder |
| 自然 | 树/岩石/喷泉 | Sphere + 噪声 |
八、从零实现一个「花瓶」完整示例
Step 1:定义参数
javascript
// categories/daily.js
export const Vase = createAsset({
name: 'Vase',
paramData: {
height: { default: 30, type: 'cm', min: 15, max: 50, folder: 'Body', name: 'Height' },
bodySize: { default: 14, type: 'cm', min: 8, max: 22, folder: 'Body', name: 'Size' },
neckSize: { default: 6, type: 'cm', min: 4, max: 12, folder: 'Neck', name: 'Size' },
bulge: { default: 20, type: '%', min: 0, max: 60, folder: 'Body', name: 'Bulge' },
...COMMON_COMPLEXITY
},
Step 2:写 build 逻辑
javascript
build({ params: p, material: mat, add, ASSETS }) {
const h = cm(p.height)
const bs = cm(p.bodySize / 2)
const ns = cm(p.neckSize / 2)
const bulge = 1 + p.bulge / 100
add(new THREE.Mesh(
new ASSETS.LatheUVGeometry([
[0, 0, bs * 0.7],
[0, h * 0.4, bs * bulge],
[0, h * 0.85, bs * 0.8],
[0, h, ns]
], segs(p)),
mat
))
}
})
Step 3:注册到 catalog
javascript
// catalog.js → daily 分类
{ name: 'Vase 花瓶', class: Daily.Vase }
Step 4:页面中使用
javascript
import { getAssetByIndex } from '@/assets/catalog.js'
const asset = getAssetByIndex(42)
const instance = new asset.class(asset.class.defaults)
sceneRef.value.addObject(instance)
完成。 打开页面,拖动 Height / Bulge 滑块,花瓶实时变形。
九、性能与踩坑指南
9.1 必须 dispose 旧几何体
javascript
dispose() {
this.body?.geometry.dispose()
this.clear()
}
不 dispose 会导致 WebGL 显存泄漏,滑块拖久了页面越来越卡。
9.2 Vite + dayjs 报错
Element Plus 依赖 dayjs,若 vite.config.js 设置了 optimizeDeps.noDiscovery: true,会报:
text
does not provide an export named 'default'
解法:
javascript
// vite.config.js
optimizeDeps: {
include: ['dayjs', 'element-plus', '@element-plus/icons-vue']
}
9.3 大物体相机距离
建筑类物体(墙/塔)尺寸可达 3~5 米,记得:
OrbitControls.maxDistance设足够大(如 30)- 或在加入场景前
instance.scale.setScalar(5)统一缩放
9.4 随机生成的「可控随机」
javascript
// 用 Asset.random() 而不是 Math.random() 裸调
const params = Assets.Mug.random()
mug.generate(params)
paramData 里的 min/max/chance/exp 保证随机结果永远合法。
十、可以往哪延伸?
这套架构天然适合:
- 大屏 3D 编辑器 ------ 拖拽资产 + 右侧属性面板(已实现雏形)
- 低代码 3D 场景搭建 ------ catalog 即组件库
- AI 生成参数 ------ LLM 输出 JSON →
generate(json)直接出模型 - 导出 glTF ------ Three.js
GLTFExporter把程序化结果烘焙成静态文件 - 游戏关卡道具 ------
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 |
一句话总结:
程序化 3D =
paramData定义「能调什么」+generate()定义「怎么造」+ Vue 滑块定义「怎么交互」。
三者解耦,才能从 1 个杯子扩展到 70+ 个物体而不崩。
附录:本地运行
克隆本仓库即可跑通文中所有示例:
bash
git clone https://github.com/qdcxj/three.js-3d-assets.git
cd three.js-3d-assets
npm install
npm run dev
默认地址:http://localhost:3000
| 路由 | 说明 |
|---|---|
/ |
首页 --- 资产卡片概览 |
/demo |
Demo --- 分卡片展示 |
/all-in-one |
全场景 --- 多物体同屏 + 参数面板 |
完整 README:github.com/qdcxj/three...
互动引导: 文末可加「你最想用程序化生成什么物体?评论区告诉我,下期实现。」
如果这篇文章对你有帮助,欢迎 点赞 / 收藏 / 关注 。
源码仓库:github.com/qdcxj/three... --- Star 支持一下 🌟
后续会继续更新:glTF 导出、AI 驱动画参数、分类资产扩展等进阶内容。