一句话描述想要的场景,一路跑完“文本扩写 → 单个 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 能根据你输入的话回应,世界就从“能走”变成“能聊”。
先做哪条不重要,重要的是保持“蓝图—资产—拼装—运行时”四段解耦:每一段都单独可测,出了问题时能立刻定位到哪一段该重跑,而不是推倒重来。
