跳到主内容
快讯直播
AI智模界
教程

用 VAST 开源链路,把一句话变成可玩 3D 世界

一句话描述想要的场景,一路跑完“文本扩写 → 单个 3D 资产生成 → 场景拼装 → 可交互运行时 → 导出资产”这条链,最后在浏览器里用 WASD 走进去逛一圈,并把成果导出成 GLB/USDZ 交给引擎或建模同事继续用。这就是本篇要交付的结果:一个几十米见方、有十来件自生成道具、能走动、能按 E 触发的迷你 3D 世界。

前置条件清单

动手前先把这些备齐,缺一项都会在中途卡住。

  • 一台带独立显卡的机器,Linux 或 Windows + WSL2 均可;纯 CPU 也能跑通小模型,但批量生成会明显变慢。
  • Python 3.10 及以上、git、git-lfs(模型权重通常走 LFS)、Node.js 与 npx(后面做资产压缩用)。
  • 至少预留几十 GB 磁盘:一个场景十来件资产,加上扩散模型权重和中间缓存,增长很快。
  • Docker(可选但建议):官方镜像能省掉一多半的依赖冲突问题。
  • Blender 命令行版(用于无损拼装和格式转换),版本以 Blender 官方下载页当前稳定版为准。
  • 一个能读写 JSON 的大模型接口或本地模型(用来把一句话扩写成场景蓝图),不是必须走云端,本地 7B 级别足以胜任这种结构化输出。

VAST 开源的是若干可复用组件,不是一个装好就能玩的成品游戏。仓库地址、模型权重清单、推理入口的参数名,都以官方仓库 README 与模型卡当前版本为准;下文出现的命令结构通用,但具体模块名请按官方文档替换。

分步骤

第 1 步:把整条链路拆成四段

先建立心智模型,后面每一步都可单独调试:

1. 蓝图:一句话 → 结构化 JSON(有哪些物体、摆在哪、什么尺寸、哪些能交互)。

2. 资产:JSON 里每条 prompt → 一个 .glb 单体模型。

3. 拼装:按 JSON 里的坐标把单体模型合成一个 scene.glb。

4. 运行时:加载 scene.glb,加相机、碰撞、交互,最后导出。

这四段之间只靠文件通信,任何一段坏了都能单独重跑,这对生成类任务非常重要。

第 2 步:准备环境

```bash

python -m venv .venv

source .venv/bin/activate # Windows: .venv\Scripts\activate

pip install -U pip

git lfs install

```

克隆官方仓库(地址以 VAST 官方开源组织页面为准),装依赖。如果官方提供镜像,优先用镜像,比在本地解依赖树省事:

```bash

git clone <官方仓库地址>

cd <仓库目录>

pip install -r requirements.txt # 依赖清单以仓库当前版本为准

```

权重下载用官方提供的脚本或 Hugging Face CLI,支持断点续传,别用浏览器硬拖:

```bash

huggingface-cli download <官方模型仓库> --local-dir ./weights

```

跑一个最小样例确认环境通了:生成单个物体并保存成 .glb。官方通常提供三种入口——命令行推理脚本、Gradio/WebUI 页面、ComfyUI 节点,任选其一,先跑通一个再谈批量。

第 3 步:一句话扩写成场景蓝图

这一步决定了成品像不像样。让大模型输出固定 schema 的 JSON,别让它自由发挥散文。系统提示可以这样写:

```text

你是 3D 场景策划。把用户的一句话扩写成 JSON 蓝图,字段固定为:

world_name, style, ground{size:[宽,深], material}, lighting{time, key, fog_density},

player{spawn:[x,y,z], speed},

assets[]{id, prompt, position:[x,y,z], rotation_y, scale, collider},

interactions[]{target, type, hint}

规则:

1. assets 数量 8–20,prompt 只描述单个物体,写清材质与颜色;

2. 单位是米,Y 轴向上,地面在 y=0,物体 position 的 y 为模型底部落点;

3. collider 取 box 或 none;

4. 只输出 JSON,不要解释。

```

一份可用的蓝图长这样:

```json

{

"world_name": "雨夜便利店街角",

"style": "写实偏冷调,霓虹反光",

"ground": { "size": [40, 40], "material": "wet_asphalt" },

"lighting": { "time": "night", "key": "neon", "fog_density": 0.02 },

"player": { "spawn": [0, 1.7, 8], "speed": 3.2 },

"assets": [

{ "id": "store_front", "prompt": "便利店玻璃门面,暖白灯箱招牌,金属门框",

"position": [0, 0, -6], "rotation_y": 0, "scale": 1.0, "collider": "box" },

{ "id": "vending_machine", "prompt": "自动售货机,深蓝色外壳,发光展示窗",

"position": [4.5, 0, -5], "rotation_y": -90, "scale": 1.0, "collider": "box" },

{ "id": "trash_bin", "prompt": "街角金属垃圾桶,带少许磨损",

"position": [-3, 0, -4], "rotation_y": 30, "scale": 1.0, "collider": "box" }

],

"interactions": [

{ "target": "store_front", "type": "open", "hint": "按 E 推门" }

]

}

```

蓝图落盘后加一层手写校验,别盲信模型输出:数组长度、坐标是否落在 ground.size 范围内、id 是否重复、collider 取值是否合法。校验不过就让模型重生成一次,比后面调试场景塌陷省事得多。

第 4 步:批量把 prompt 变成单体模型

按蓝图逐条调用推理入口,已存在的文件跳过,天然支持断点续跑:

```python

import json, pathlib, subprocess

root = pathlib.Path(".").resolve()

blueprint = json.loads((root / "blueprint.json").read_text(encoding="utf-8"))

out_dir = root / "assets"

out_dir.mkdir(exist_ok=True)

for item in blueprint["assets"]:

dst = out_dir / f'{item["id"]}.glb'

if dst.exists():

print("skip", dst.name)

continue

cmd = [

"python", "-m", "官方推理入口", # 模块名与参数以官方 README 为准

"--prompt", item["prompt"],

"--output", str(dst),

]

print("run", " ".join(cmd))

subprocess.run(cmd, check=True)

```

批量跑到一半显存不够时,把并发降到 1、缩短 prompt、降低采样分辨率;生成质量差时反过来,先把单条 prompt 调好再批量。别指望一次生成就得到多个角度都正确的模型,单体资产有背面塌陷是常态,靠摆放位置和朝向藏起来即可。

第 5 步:规范化与拼装

生成出来的资产通常尺度不一、贴图偏大、原点不在底部。先统一处理再拼装:

```bash

npx @gltf-transform/cli optimize assets/vending_machine.glb assets/vending_machine.opt.glb \

--texture-compress webp --texture-size 1024

```

拼装用 Blender 无头模式最省事,它对 glTF 的支持稳,还能顺手把碰撞信息写进节点名。注意坐标转换:蓝图用 glTF 的 Y 轴向上,Blender 是 Z 轴向上,导入时按 (x, -z, y) 换算。

```python

assemble.py —— blender -b -P assemble.py

import bpy, json, math, pathlib

root = pathlib.Path(__file__).parent

bp = json.loads((root / "blueprint.json").read_text(encoding="utf-8"))

bpy.ops.wm.read_factory_settings(use_empty=True)

for item in bp["assets"]:

path = root / "assets" / f'{item["id"]}.glb'

if not path.exists():

print("missing", path)

continue

before = set(bpy.data.objects)

bpy.ops.import_scene.gltf(filepath=str(path))

new_objs = [o for o in bpy.data.objects if o not in before]

anchor = bpy.data.objects.new(item["id"], None)

bpy.context.collection.objects.link(anchor)

for obj in new_objs:

if obj.parent is None:

obj.parent = anchor

obj["collider"] = item.get("collider", "none")

x, y, z = item["position"]

anchor.location = (x, -z, y) # glTF(Y-up) -> Blender(Z-up)

anchor.rotation_euler = (0, 0, math.radians(item.get("rotation_y", 0)))

s = item.get("scale", 1.0)

anchor.scale = (s, s, s)

bpy.ops.export_scene.gltf(

filepath=str(root / "build" / "scene.glb"),

export_format="GLB",

export_apply=True,

)

```

第 6 步:接上可交互运行时

用 Three.js 做浏览器端运行时,加载 scene.glb,加第一人称漫游和 AABB 碰撞。下面这份 HTML 可以直接跑(放到本地 HTTP 服务下,不要用 file:// 打开):

```html

<!DOCTYPE html>

<html lang="zh-CN">

<head>

<meta charset="utf-8">

<title>一句话世界</title>

<style>

html,body{margin:0;height:100%;overflow:hidden;background:#06070c}

#hud{position:fixed;left:16px;bottom:16px;color:#dfe6ff;

font:14px/1.6 system-ui,sans-serif;pointer-events:none;white-space:pre}

#tip{position:fixed;left:50%;top:60%;transform:translateX(-50%);

color:#fff;font:15px/1.5 system-ui;display:none}

</style>

<script type="importmap">

{

"imports": {

"three": "https://unpkg.com/three/build/three.module.js",

"three/addons/": "https://unpkg.com/three/examples/jsm/"

}

}

</script>

</head>

<body>

<div id="hud">点击画面开始 · WASD 移动 · Shift 加速 · Esc 退出</div>

<div id="tip"></div>

<script type="module">

import * as THREE from 'three';

import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

import { PointerLockControls } from 'three/addons/controls/PointerLockControls.js';

import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';

const PLAYER_RADIUS = 0.35, EYE = 1.7, GRAVITY_Y = 0;

const renderer = new THREE.WebGLRenderer({ antialias: true });

renderer.setPixelRatio(Math.min(devicePixelRatio, 2));

renderer.setSize(innerWidth, innerHeight);

renderer.toneMapping = THREE.ACESFilmicToneMapping;

document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();

scene.background = new THREE.Color(0x0a0d18);

scene.fog = new THREE.FogExp2(0x0a0d18, 0.02);

const pmrem = new THREE.PMREMGenerator(renderer);

scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;

const camera = new THREE.PerspectiveCamera(65, innerWidth / innerHeight, 0.1, 500);

camera.position.set(0, EYE, 8);

const controls = new PointerLockControls(camera, renderer.domElement);

document.body.addEventListener('click', () => controls.lock());

const colliders = [];

const interactables = [];

new GLTFLoader().load('./build/scene.glb', (gltf) => {

scene.add(gltf.scene);

gltf.scene.updateMatrixWorld(true);

gltf.scene.traverse((o) => {

if (!o.isMesh) return;

const box = new THREE.Box3().setFromObject(o);

let p = o;

let tagged = false;

while (p) {

if (p.userData && p.userData.collider === 'box') tagged = true;

p = p.parent;

}

if (tagged) colliders.push(box);

interactables.push(o);

});

camera.position.set(0, EYE, 8);

});

const keys = new Set();

addEventListener('keydown', (e) => keys.add(e.code));

addEventListener('keyup', (e) => keys.delete(e.code));

function blocked(pos) {

for (const b of colliders) {

const box = b.clone().expandByScalar(PLAYER_RADIUS);

if (pos.x > box.min.x && pos.x < box.max.x &&

pos.z > box.min.z && pos.z < box.max.z &&

pos.y > box.min.y - 0.2 && pos.y < box.max.y + 0.2) return true;

}

return false;

}

const raycaster = new THREE.Raycaster();

const tip = document.getElementById('tip');

let hovered = null;

const clock = new THREE.Clock();

function animate() {

requestAnimationFrame(animate);

const dt = Math.min(clock.getDelta(), 0.05);

if (controls.isLocked) {

const speed = (keys.has('ShiftLeft') ? 6 : 3.2) * dt;

const dir = new THREE.Vector3();

if (keys.has('KeyW')) dir.z -= 1;

if (keys.has('KeyS')) dir.z += 1;

if (keys.has('KeyA')) dir.x -= 1;

if (keys.has('KeyD')) dir.x += 1;

if (dir.lengthSq() > 0) {

dir.normalize().applyQuaternion(camera.quaternion);

dir.y = 0; dir.normalize().multiplyScalar(speed);

const next = camera.position.clone();

next.x += dir.x;

if (!blocked(next)) camera.position.x = next.x;

const next2 = camera.position.clone();

next2.z += dir.z;

if (!blocked(next2)) camera.position.z = next2.z;

camera.position.y = EYE; // 平地场景先锁高度,接入地形再放开

}

raycaster.setFromCamera(new THREE.Vector2(0, 0), camera);

const hits = raycaster.intersectObjects(interactables, false)

.filter((h) => h.distance < 3);

hovered = hits.length ? hits[0].object : null;

tip.style.display = hovered ? 'block' : 'none';

if (hovered) tip.textContent = '按 E 交互:' + hovered.name;

}

renderer.render(scene, camera);

}

animate();

addEventListener('keydown', (e) => {

if (e.code === 'KeyE' && hovered) {

hovered.rotation.y += Math.PI / 2; // 占位:换成开门、拾取等逻辑

}

});

addEventListener('resize', () => {

camera.aspect = innerWidth / innerHeight;

camera.updateProjectionMatrix();

renderer.setSize(innerWidth, innerHeight);

});

</script>

</body>

</html>

```

起个本地服务看效果:

```bash

python -m http.server 8080

浏览器打开 http://localhost:8080

```

碰撞体靠 userData.collider 标记传递,所以第 5 步 Blender 里给锚点写的 collider 属性要确认导出后还在;如果丢了,就改成按节点名约定(比如带 _col 后缀)来识别。

第 7 步:导出与交付

交付主线是 GLB,Web、Unity、Godot、UE、Blender 都能直接吃:

```bash

mkdir -p build

npx @gltf-transform/cli optimize build/scene.glb dist/scene.glb \

--texture-compress webp --texture-size 1024

npx @gltf-transform/cli inspect dist/scene.glb

```

  • 需要苹果生态(AR Quick Look)就转 USDZ,用 Blender 或官方 usdzconvert 以各自文档为准。
  • 需要给传统 DCC 流水线就导出 FBX,注意 FBX 没有 glTF 的 PBR 约定,材质要重连。
  • 上网页前再压一道:几何用 Draco 或 meshopt,贴图转 KTX2,命令同样是 @gltf-transform/cli 的子命令,参数以该工具官方文档为准。

验收清单:模型能加载无报错、走进墙里会停住、贴图不糊不黑、帧率在目标设备上稳定、inspect 输出的贴图尺寸和三角面数在预算内。

常见坑与排错

  • 加载后一片漆黑:多半没环境光照。补 RoomEnvironment 或 HDR 环境贴图,别只加一盏平行光。
  • file:// 打开报跨域:GLB 必须经 HTTP 加载,用 python -m http.server 或任意静态服务器。
  • 资产歪七扭八、大小不一:生成结果尺度不统一是常态。在 Blender 里按目标尺寸整体缩放,并把原点设到底部中心,别在运行时逐个数值去猜。
  • 模型朝向不一致:glTF 惯例是 −Z 朝前,用 rotation_y 集中管控,别每个物体单独微调,否则换一批资产就要重调一遍。
  • 碰撞穿模或卡住:AABB 是粗近似,门口、斜放物体用盒子会堵住。把这类物体的 collider 设为 none,或拆成多个小盒子。
  • 显存爆掉:批量化时把并发压到 1,减小采样分辨率与贴图尺寸,长 prompt 拆短。
  • 权重下载中断:用 Hugging Face CLI 重跑,支持续传;别删半成品目录重下。
  • 场景加载慢:先合并网格减少 draw call,再对大贴图降分辨率,必要时按区域拆分 GLB 做按需加载。

下一步建议

链路跑通后,有三条明显的延伸路线。一是把静态蓝图升级成“同一句话生成多个变体”,用不同随机种子出三版布局,挑一版继续深化;二是把 AABB 换成真正的物理引擎(Rapier 或 cannon-es),让推门、掉落、把椅子撞倒这些行为自然发生;三是把 interactions 里的占位逻辑接上大模型,让场景里的 NPC 能根据你输入的话回应,世界就从“能走”变成“能聊”。

先做哪条不重要,重要的是保持“蓝图—资产—拼装—运行时”四段解耦:每一段都单独可测,出了问题时能立刻定位到哪一段该重跑,而不是推倒重来。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。