不写模型文件,用代码「捏」出 3D 世界:Vue3 + Three.js 程序化资产生成实战

不写模型文件,用代码「捏」出 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 图集打包)                │
└─────────────────────────────────────────────────────────┘

数据流(调参时发生了什么):

sequenceDiagram participant UI as 参数滑块 participant Vue as AllInOne.vue participant Asset as Asset.generate() participant Three as Three.js Scene UI->>Vue: 滑块 input 事件 Vue->>Vue: 更新 currentParams Vue->>Asset: generate({ ...params }) Asset->>Asset: dispose() 销毁旧几何体 Asset->>Asset: 重建 Mesh 并 add 到 Group Three->>Three: 下一帧渲染新模型

二、项目目录说明

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 ------ 适合箱子、设备外壳

RoundBoxRoundedBoxGeometry 一次生成带倒角的 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 图集 ------ 适合复杂家具

TableChairWardrobe 这类家具有 异形截面 + 曲线路径挤出

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.jsMAXRECTS-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 压力也可控。

完整交互链路:

  1. 下拉框选择资产 → new AssetClass(defaults) → 加入场景
  2. 读取 AssetClass.paramData → 渲染滑块 / 开关
  3. 滑块拖动 → 更新 currentParamsgenerate() → 模型实时变化
  4. 点击「随机生成」→ 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 保证随机结果永远合法。


十、可以往哪延伸?

这套架构天然适合:

  1. 大屏 3D 编辑器 ------ 拖拽资产 + 右侧属性面板(已实现雏形)
  2. 低代码 3D 场景搭建 ------ catalog 即组件库
  3. AI 生成参数 ------ LLM 输出 JSON → generate(json) 直接出模型
  4. 导出 glTF ------ Three.js GLTFExporter 把程序化结果烘焙成静态文件
  5. 游戏关卡道具 ------ 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 驱动画参数、分类资产扩展等进阶内容。

相关推荐
灰子学技术14 小时前
Envoy OAuth2 过滤器功能实现分析
运维·服务器·前端·网络
LCG元14 小时前
MySQL慢查询分析与索引调优:从故障诊断到性能翻倍的进阶之路
android·前端·mysql
天涯明月199315 小时前
后端工程师全栈转型前端入门
前端·状态模式·全栈工程师
invicinble15 小时前
springboot出现的原因二---作为web的后端服务一站式整合器
前端·spring boot·后端
kyriewen15 小时前
前端初级岗位暴跌62%:我带了三年的实习生被裁了,而AI是他亲手教的
前端·面试·ai编程
劉三岁15 小时前
Git 给 main 分支打 Tag(版本标记)完整教程
vue.js·github
zifengningyu15 小时前
【无标题】
前端·vue.js
JiaWen技术圈15 小时前
React Server Functions 深度解析
前端·react.js·前端框架
幸运小圣15 小时前
前端三种输入数据来源生成 worksheet(工作表)新手适用详细篇【SheetJS】
开发语言·前端·javascript