用代码构建3D世界:Vue3联合Three.js实现程序化资产生成实战

作者:袖梨 2026-05-29

程序化生成3D资产正在改变传统建模流程,本文将详解如何通过Vue3+Three.js实现参数化建模,从架构设计到具体实现完整解析。

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

本文对应开源仓库:qdcxj/three.js-3d-assets

基于原项目:boytchev/3d-assets 的 Vue 3 二次开发版本


效果预览

项目Demo展示页包含16种程序化3D资产,每个模型都支持通过重新生成功能创建随机变体。

img_6a18d897898a430.webp


前言:为什么不用 .glb

传统3D页面开发流程存在四个典型痛点,程序化生成提供了更优解决方案:

问题 程序化生成的解法
改尺寸要重新建模 改一个数字,几何体实时重建
100 个变体 = 100 个文件 一套 paramData + generate() 覆盖无限变体
下载体积大 纯 JS 计算,KB 级代码
UI 滑块无法联动 参数即 API,天然对接表单

程序化生成的核心优势在于参数化建模,本项目通过Vue3改造实现了16种物体的实时调参和场景组合展示功能。


一、整体架构

┌─────────────────────────────────────────────────────────┐
│                    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 图集打包)                │
└─────────────────────────────────────────────────────────┘

二、项目目录说明

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):

// 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 标准资产模板

每个物体文件都遵循 四件套

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 不仅描述几何计算,还直接驱动前端参数面板:

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
  }
}

设计要点:

  1. cm / deg / % 等业务单位,别直接暴露 Three.js 坐标
  2. folder + name 让属性面板自动分组,无需手写表单
  3. min/max 保证滑块不会生成非法几何

四、三种几何构建方式(由简到难)

4.1 旋转体 Lathe —— 适合杯、盘、瓶

原理: 在 XY 平面画轮廓点,绕 Y 轴旋转一圈。

Plate(盘子)为例:

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:

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 这类家具有 异形截面 + 曲线路径挤出

// 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,避免纹理浪费:

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 管理生命周期

// 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


父组件通过 ref 调用:

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() 原地换几何体

// AllInOne.vue 核心逻辑
const currentParams = reactive({})
let currentObject = null
let refreshFrameId = nullfunction 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 —— 资产工厂

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

相关文章

精彩推荐