
GPT-Astra 不是传统意义上的建模软件而是一类“一次生成可探索科幻飞船”的生成式场景项目。核心链路很短用户输入一段描述例如“一艘用于深空科考的飞船冷色调包含舰桥、宿舍区、引擎室走廊连接”大语言模型在一次回复里输出一整份结构化的飞船场景描述渲染端读取这份描述把地板、墙体、门洞、控制台、全息屏幕、灯光和碰撞体全部构建出来。玩家进入场景后可以第一人称视角行走在舰桥查看控制台沿着走廊进入引擎室。这里最值得拆开的技术点是“结构数据 确定性构建”的分离模型负责布局语义代码负责几何与交互。手工搭建一条科幻走廊可能要花几个小时而这种方案把建模过程中可参数化的部分变成规则模型只需要输出房间尺寸、位置、门洞方向和道具清单。下面用最小可运行项目把这条链路走通技术栈使用 Node.js、Three.js 和 OpenAI Chat Completions 接口。跑完后你会得到一条完整的“文字描述 - 场景 JSON - 可探索 3D 飞船”流水线并知道每一步出现问题时该查哪里。1. 先把“生成可探索飞船”拆成三个核心问题1.1 场景描述、几何构建、交互探索要互相解耦一个可复现的生成式 3D 管线不能把“写描述”和“画几何”混在一起。大模型擅长写文案、列清单、安排房间关系但直接让它输出二进制的 glTF 或精细网格并不稳定。反过来让代码凭空理解“科幻引擎室”是什么样能力又不够。所以要把流程拆成三层场景描述层定义飞船有哪些房间、房间的尺寸位置、门开在哪面墙、放哪些道具、用什么配色和光照。几何构建层用一段确定性的 Three.js 代码把场景描述转换成真实可渲染的 Mesh。交互探索层给玩家第一人称相机、碰撞检测和基本移动规则让玩家真的“走进”这艘船。分层之后每一层都可以单独验证。生成结果不对去查 Prompt 和 JSON 校验构建结果不对去查 Builder 和坐标逻辑交互手感不对去查相机和碰撞体。GPT-Astra 这类项目能稳定落地不是因为模型本身多聪明而是因为模型只负责它擅长的部分其余部分由确定代码兜底。1.2 为什么“一次生成”的最终产物是 JSON而不是模型文件“一次生成”听起来像一句提示词直接产出可导入 Blender 的模型实际工程里并不是这样。更稳的做法是让模型输出一份 JSON 场景描述。JSON 有几个明显优势可校验用 JSON Schema 或手写校验函数能把结构错误挡在渲染之前。可版本化场景结构变化时可以做 diff甚至可以保留多个版本。可回退如果某一条生成结果不合格可以把 JSON 回滚到上一份成功结果。可读性好房间、门洞、彩色值都肉眼可查调试成本低。在 GPT-Astra 的链路里“一次生成”通常指用户给一条完整需求后模型一次性返回一份完整的布局方案而不是多轮对话逐步追加房间。渲染端拿到 JSON 再构建场景因此模型是否一次输出成功主要取决于 Prompt 和响应格式约束。1.3 “可探索”不是加一个相机而是加一套规则很多 3D 示例项目做成“转盘效果”鼠标拖动视角绕模型旋转。那不算可探索。真正的可探索要求玩家位置在飞船内部视角受房间约束移动时会撞墙而不是穿模。要达成这个效果至少要有四件事房间高度天花板不能低于玩家身高太多否则视野压迫感强。门洞宽度门洞至少要比玩家半径大否则走不过去。碰撞集合所有墙体、道具都登记为碰撞体。相机控制使用 Pointer Lock 或类似方案让鼠标控制视角键盘控制位移。仅这一小节就能看出GPT-Astra 的关键不是“像不像飞船”而是“能不能走通”。这也是它和普通 AI 生图项目的本质区别。2. 最小可运行项目环境、目录与依赖2.1 环境要求在开始实现前先确认环境满足下表要求。项目建议要求说明Node.js18 及以上使用 Express 和 dotenv 的基础要求npm9 及以上安装依赖即可版本不用刻意追新OpenAI API Key有可用额度只放在服务端环境变量不要写进前端代码浏览器支持 WebGL 和 Pointer Lock APIChrome、Edge、Firefox 均可如果当前环境没有 Node.js先安装 LTS 版本。下面示例使用的 Three.js 版本以npm install后 lock 文件里的实际版本为准代码按 r150 之后的 API 习惯编写基本不用改动。2.2 项目结构和初始化命令这里用一个轻量的 Express 服务同时承担 API 和前端静态文件减少额外配置。项目结构如下gpt-astra-demo/ ├─ .env ├─ package.json ├─ server/ │ ├─ index.js │ └─ generate.js └─ public/ ├─ index.html ├─ main.js └─ builder.js初始化命令mkdir gpt-astra-demo cd gpt-astra-demo npm init -y npm install express openai dotenv npm install three.env文件内容OPENAI_API_KEYsk-你的密钥 PORT3000注意.env必须加入.gitignore任何情况下都不能提交到仓库。2.3 为什么用 Express 做前端静态服务前端直接访问 OpenAI 接口最省事但会把 API Key 暴露给浏览器。GPT-Astra 这类生成式项目应该让浏览器只请求自己的后端由后端持有密钥并转发请求。因此这里用 Express 做两件事提供/api/generate接口接收前端文字描述。提供静态文件和 Three.js 构建产物。浏览器页面里通过页面根路径访问main.js、builder.js通过/vendor/访问node_modules/three下的模块文件。这种服务方式适合 Demo如果是正式项目前端建议用 Vite 打包后端独立部署本文最后会展开说明。3. 场景 Schema给大模型一张严格的“图纸模板”3.1 字段设计的目标如果让模型自由发挥它会输出各种奇怪格式渲染端很难统一处理。所以第一步是定义一份最小的场景 Schema把允许出现的字段、单位、取值约束全部固定下来。Schema 需要回答这几个问题飞船整体叫什么名字用什么配色。有哪些房间每个房间的位置、尺寸、墙壁颜色、地板颜色。每个房间有哪些门洞门洞开在哪面墙宽度多少。每个房间有哪些道具道具类型、位置、朝向。字段越少越好先保证能跑通再逐步加纹理、动画和特效。3.2 一个最小的飞船场景 JSON下面是一份符合 GPT-Astra 最小 Schema 的示例输出{ schemaVersion: 1.0, shipName: Astra-7, palette: { floor: #1c2230, wall: #33415c, accent: #00d4ff }, rooms: [ { id: bridge, position: [0, 0, 0], size: [10, 3, 6], floorColor: #1c2230, wallColor: #33415c, doors: [ { wall: south, width: 2 } ], props: [ { type: console, position: [-2, 0, -1.8], rotation: 0 }, { type: screen, position: [-4.2, 1.1, 0], rotation: 1.5708 } ] }, { id: corridor, position: [0, 0, -5], size: [4, 3, 4], floorColor: #181d29, wallColor: #2a3550, doors: [ { wall: north, width: 2 }, { wall: south, width: 1.5 } ], props: [] } ] }在这个示例里bridge 的中心在[0, 0, 0]尺寸[10, 3, 6]表示宽、高、深分别为 10、3、6。corridor 的中心在[0, 0, -5]尺寸[4, 3, 4]它的 north 面对应 bridge 的 south 面两扇门宽度一致玩家才能从舰桥走进走廊。3.3 坐标系和单位约定所有生成结果都要遵循统一约定否则构建端无法计算墙体位置。y 轴向上房间高度取size[1]。position表示房间中心点坐标不是墙角坐标。1 个单位等于 1 米。方向约定north 指向 -Zsouth 指向 Zeast 指向 Xwest 指向 -X。这些约定要写进 Prompt也要写进渲染代码。位置和方向一旦混用门洞就会开到错误墙面走廊出现断头路。4. 让 LLM 稳定输出可构建的 JSON4.1 System Prompt 设计把约束写进角色和规则模型不会自动理解你的坐标系必须把规则写清楚。System Prompt 建议包含以下内容角色你是一个科幻飞船场景设计师。输出格式只输出 JSON不输出任何解释文本。Schema 定义字段含义和单位。坐标约定north -Zposition 是房间中心。规则限制每个房间至少有 1 个门洞门洞宽度在 1.2 到 3 之间。道具类型白名单console、screen、chair、light、container。一个示意 Prompt 如下你是科幻飞船场景设计师。用户会描述飞船需求你必须输出严格 JSON禁止输出 JSON 之外的文字。 JSON 字段规则 - schemaVersion: 固定 1.0 - shipName: 字符串 - palette: 包含 floor, wall, accent 三个颜色 - rooms: 房间数组每项包含 id, position, size, floorColor, wallColor, doors, props - position 是房间中心格式 [x, y, z]y 固定为 0 - size 是 [宽, 高, 深] - 坐标系north 指向 -Zsouth 指向 Zeast 指向 Xwest 指向 -X - doors 的 wall 只能是 north/south/east/westwidth 在 1.2 到 3 之间 - props 的 type 只能是 console/screen/chair/light/container - 所有房间必须通过门洞连通不能出现孤立房间 - 房间之间不能重叠 用户描述{用户输入}关键点是“禁止输出 JSON 之外的文字”。很多生成失败发生在模型多输出了一行注释或 Markdown 代码块标记导致 JSON.parse 直接报错。4.2 请求参数温度、响应格式、超时调用 Chat Completions 接口时参数对稳定性的影响很大。参数建议值说明modelgpt-4o-mini 或项目可用模型成本低足够完成结构生成temperature0.3 到 0.5温度太低像模板太高容易偏离约束response_format{ type: json_object }强制模型输出合法 JSON 对象timeout30 秒以上场景复杂时生成耗时较长temperature不建议设为 0。即使设为 0模型也不是绝对确定尤其在 JSON 对象内部字段顺序上仍可能变化。response_format只能保证 JSON 语法合法不能保证房间连通或门洞宽度合理这些要靠 Prompt 和校验层兜底。4.3 后端生成函数与失败兜底server/generate.js的核心代码如下require(dotenv).config(); const { OpenAI } require(openai); const client new OpenAI({ apiKey: process.env.OPENAI_API_KEY, timeout: 30 * 1000, }); const systemPrompt 你是科幻飞船场景设计师。...完整 Prompt 见上文...; async function generateShip(description) { const response await client.chat.completions.create({ model: gpt-4o-mini, temperature: 0.4, response_format: { type: json_object }, messages: [ { role: system, content: systemPrompt }, { role: user, content: description }, ], }); const content response.choices[0].message.content ?? ; const parsed JSON.parse(content); if (!Array.isArray(parsed.rooms) || parsed.rooms.length 0) { throw new Error(生成结果缺少 rooms 数组); } return parsed; } module.exports { generateShip };这里做了两层防护第一层用response_format保证语法合法第二层在解析后检查最基础的字段是否存在。更完整的项目还应该校验每个 room 的 id 是否重复、门洞墙名是否在白名单内、房间是否重叠。这些校验可以放在一个独立的validate.js文件里避免把所有逻辑堆在生成函数中。server/index.js提供一个/api/generate路由const express require(express); const path require(path); const { generateShip } require(./generate); const app express(); app.use(express.json()); app.use(express.static(public)); app.use(/vendor, express.static(path.join(__dirname, ../node_modules/three/build))); app.use(/vendor/addons, express.static(path.join(__dirname, ../node_modules/three/examples/jsm))); app.post(/api/generate, async (req, res) { const { description } req.body; if (!description || description.trim().length 4) { return res.status(400).json({ error: description 太短 }); } try { const ship await generateShip(description); res.json(ship); } catch (err) { console.error(err); res.status(500).json({ error: err.message }); } }); const port process.env.PORT || 3000; app.listen(port, () { console.log(GPT-Astra demo listening on http://localhost:${port}); });注意不要把process.env.OPENAI_API_KEY打印到日志里。一旦日志泄露密钥需要立刻在控制台吊销并重新生成。5. Three.js 构建可探索飞船5.1 渲染器、相机和 PointerLockControls前端入口public/main.js负责初始化渲染器、场景、相机并处理一次生成请求import * as THREE from three; import { PointerLockControls } from three/addons/controls/PointerLockControls.js; import { buildShip } from ./builder.js; const scene new THREE.Scene(); const camera new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100); const renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(innerWidth, innerHeight); document.body.appendChild(renderer.domElement); scene.add(new THREE.HemisphereLight(0xffffff, 0x33415c, 1.2)); const controls new PointerLockControls(camera, document.body); document.getElementById(startBtn).addEventListener(click, () { controls.lock(); }); function move() { const speed 0.06; if (!controls.isLocked) return; if (keys.has(KeyW)) camera.position.z - speed; if (keys.has(KeyS)) camera.position.z speed; if (keys.has(KeyA)) camera.position.x - speed; if (keys.has(KeyD)) camera.position.x speed; } setInterval(move, 16); renderer.setAnimationLoop(() renderer.render(scene, camera));这里的移动逻辑是最简写法实际项目需要把移动向量根据相机朝向换算并加入 delta 时间否则不同帧率下手感不一致。重点是PointerLockControls让鼠标可以旋转视角controls.isLocked保证只在玩家点击开始后才响应键盘。5.2 把 JSON 房间变成几何体墙体、地板、门洞public/builder.js是整个构建层的核心。它读取 JSON 里的房间数据生成地板、天花板、墙体和门洞两侧的短墙。import * as THREE from three; function createBox(width, height, depth, color) { const mesh new THREE.Mesh( new THREE.BoxGeometry(width, height, depth), new THREE.MeshStandardMaterial({ color }) ); mesh.castShadow true; mesh.receiveShadow true; return mesh; } function addWallWithDoor(room, side, door) { const h room.size[1]; const group new THREE.Group(); if (side north || side south) { const wallWidth room.size[0]; const segWidth (wallWidth - door.width) / 2; if (segWidth 0.1) return group; [-1, 1].forEach((sign) { const x room.position[0] sign * (door.width / 2 segWidth / 2); const wall createBox(segWidth, h, 0.2, room.wallColor); wall.position.set(x, h / 2, side north ? room.position[2] - room.size[2] / 2 : room.position[2] room.size[2] / 2 ); group.add(wall); }); } else { const wallDepth room.size[2]; const segWidth (wallDepth - door.width) / 2; if (segWidth 0.1) return group; [-1, 1].forEach((sign) { const z room.position[2] sign * (door.width / 2 segWidth / 2); const wall createBox(0.2, h, segWidth, room.wallColor); wall.position.set( side east ? room.position[0] room.size[0] / 2 : room.position[0] - room.size[0] / 2, h / 2, z ); group.add(wall); }); } return group; } export function buildShip(ship) { const root new THREE.Group(); for (const room of ship.rooms) { const [x, y, z] room.position; const [w, h, d] room.size; const floor createBox(w, 0.2, d, room.floorColor); floor.position.set(x, 0, z); root.add(floor); const ceil createBox(w, 0.2, d, room.wallColor); ceil.position.set(x, h, z); root.add(ceil); [north, south, east, west].forEach((side) { const hasDoor room.doors.filter((d) d.wall side); if (hasDoor.length 0) { hasDoor.forEach((door) root.add(addWallWithDoor(room, side, door))); } else { const wall buildSolidWall(room, side); root.add(wall); } }); (room.props || []).forEach((prop) root.add(createProp(prop))); } return root; }createBox使用BoxGeometry创建墙体和地板。门洞处理的关键在于有门的墙面不是一整面墙而是分成左右两段短墙中间空出门的宽度。这样玩家既能在视觉上看到门洞又能在碰撞体上真正通过。5.3 道具、灯光和碰撞体道具生成逻辑要根据prop.type分发function createProp(prop) { const group new THREE.Group(); switch (prop.type) { case console: group.add(createBox(2, 0.9, 0.8, 0x222831)); group.add(createBox(1.8, 0.1, 0.6, 0x00d4ff)); break; case screen: { const screen createBox(3, 1.8, 0.15, 0x001f2f); group.add(screen); break; } case chair: group.add(createBox(0.6, 0.6, 0.6, 0x303030)); break; case light: group.add(createBox(0.4, 0.1, 1.2, 0xffe9a8)); break; default: break; } group.position.set(prop.position[0], prop.position[1], prop.position[2]); if (prop.rotation) group.rotation.y prop.rotation; return group; }碰撞体是所有 Mesh 包围盒的集合export function collectColliders(root) { const boxes []; root.traverse((obj) { if (obj.isMesh) { const box new THREE.Box3().setFromObject(obj); boxes.push(box); } }); return boxes; } export function collides(pos, radius, boxes) { for (const box of boxes) { const dx Math.max(box.min.x - pos.x, 0, pos.x - box.max.x); const dz Math.max(box.min.z - pos.z, 0, pos.z - box.max.z); if (dx * dx dz * dz radius * radius) return true; } return false; }在键盘移动逻辑里计算目标位置后先调用collides如果碰撞则取消该方向位移这样玩家会被墙体挡住而不是穿过去。注意这是一个简化碰撞方案。房间数量多、道具复杂时建议使用 Cannon-es 或 Rapier 物理引擎或预烘焙导航网格否则逐帧遍历所有 Mesh 包围盒会产生不必要的性能损耗。6. 运行验证从浏览器走一遍完整流程6.1 启动服务并取回生成结果在项目根目录执行node server/index.js输出GPT-Astra demo listening on http://localhost:3000浏览器打开http://localhost:3000在文本框输入“一艘科考飞船包含舰桥、宿舍区和引擎室冷色调有全息星图屏幕”点击生成。在浏览器开发者工具 Network 面板可以看到一个 POST 请求/api/generate响应体是 JSON 场景数据。也可以直接用 curl 测试后端curl -X POST http://localhost:3000/api/generate \ -H Content-Type: application/json \ -d {description:一艘小型科考飞船舰桥连接走廊走廊通向引擎室}正常返回的结果包含schemaVersion、shipName、palette、rooms四个顶层字段。6.2 在 3D 场景里验证可探索性生成成功后页面会自动把 JSON 交给buildShip构建场景。点击“开始探索”锁定鼠标使用 WASD 行走。可以按下面的清单逐项验证检查项预期结果失败时的现象房间结构能看到地板、天花板、四面墙只看到低多边形网格缺墙或重叠门洞能从一个房间走入门洞进入下一个房间门洞被墙堵住或宽度过窄碰撞走到墙边会被挡住玩家穿模进入墙体内部道具舰桥有控制台、屏幕引擎室有设备和灯道具位置漂移到墙外光照室内整体清晰不会全黑或过曝场景太暗或强光刺眼配色接近用户要求的冷色调颜色随机与描述不符6.3 生成 JSON 与渲染结果的对照这一步最容易发现“模型生成得对但渲染代码没读懂”的问题。把 Network 面板里的 JSON 复制下来和 3D 场景逐条对照。例如 JSON 说 bridge 的position: [0, 0, 0]那么在场景里桥的中心应该落在原点附近JSON 说 corridor 的 north 门宽 2那么场景里走廊北侧应该有一个 2 米宽的门洞。如果 JSON 正确但场景错误问题在构建层如果 JSON 本身房间重叠或门洞开错方向问题在 Prompt 和校验层。两者不要混在一起排查。7. 常见问题排查分开查生成层、构建层和交互层7.1 生成层JSON 解析失败、房间重叠、门洞开错墙JSON 解析失败是最常见的现象。可能原因包括模型输出了 Markdown 代码块、尾部多了一个逗号、字段值不是合法数字。检查方式是在生成函数里打印response.choices[0].message.content原始内容用 JSON.parse 定位错误位置。解决方式是把响应格式约束和“只输出 JSON”写进 Prompt并视情况重试一次。房间重叠通常发生在模型没有做碰撞计算时。生成结果里两个房间的包围盒相交渲染端会出现墙体穿插。建议在校验层计算每对房间的 AABB 重叠一旦超过阈值就触发重试。门洞开错墙则说明坐标系约定没有被模型理解需要在 Prompt 里把方向规则写在更显眼的位置并在校验时检查相邻房间的门洞是否对齐。7.2 构建层墙错位、门洞被堵、道具加载失败墙错位多是因为把position当成了墙角而代码里按中心点计算。排查时先在 Builder 里打印房间中心和四墙位置和 JSON 数值逐一对比。门洞被堵通常是门洞所在墙面同时生成了整墙和短墙要去掉整墙分支只保留有门洞的短墙。道具加载失败一般是prop.type不在白名单内Builder 走到default分支直接跳过校验层需要提前拦截未知类型。7.3 交互层穿模、卡死、性能差、灯光过暗穿模有三个常见原因碰撞体没有收集全、门洞两侧的短墙没加 Mesh 所以没有包围盒、移动步长过大导致角色在高速时跳过了薄墙。解决方案是每帧移动时把位移拆成多个小步长并让碰撞判断使用预期位置而不是当前位置。相机卡死可能是因为 PointerLock 没有获得锁定也可能因为出生点落在碰撞盒内部。更好的做法是生成后把玩家放在第一个房间中心偏安全区域并避开道具。性能差主要来自碰撞检测遍历所有 Mesh 包围盒优化方向是离线把碰撞盒烘焙成静态数组运行时只检查附近的几十个盒。灯光过暗通常是半球光强度不足或者场景没有添加环境贴图可以先用HemisphereLight提高系数。问题现象常见原因检查方式处理建议JSON 解析失败模型输出了非 JSON 文本打印原始 content强化 Prompt 并使用 response_format房间重叠模型没做包围盒计算校验 AABB 重叠量校验失败自动重试门洞方向错坐标系约定没被遵守对照相邻房间坐标Prompt 中强调方向映射玩家穿模碰撞体不完整或步长过大打印碰撞盒列表拆分移动步长并补齐碰撞盒场景过暗光照强度不足调整 HemisphereLight 参数增加到 1.2 到 2.0 之间8. 从 Demo 到生产最佳实践与扩展方向8.1 落地前必改的安全与稳定性清单把示例推到生产环境前建议按下面的清单逐项检查API Key 只存在于服务端环境变量前端永远不接触。为/api/generate增加限流避免单用户高频调用导致费用失控。对生成结果做严格校验至少检查字段类型、枚举值、房间连通性。增加超时和重试捕获超时后返回可读错误而不是让请求一直悬挂。记录生成日志但日志中不要包含完整 Prompt 里的业务敏感信息也不要包含 API Key。增加缓存用用户输入文本的哈希作为 key重复需求直接返回缓存场景。前端增加错误提示失败时显示“生成失败请重试”而不是白屏。8.2 让生成更可控的工程策略从示例升级到正式项目最值得做的改造是引入 JSON Schema 校验工具例如 zod 或 ajv。这样字段结构、枚举值、范围都能在代码层强制校验避免模型偶然输出不合规数据。其次建议为场景描述增加版本号未来扩展房间类型时旧场景仍然能被旧 Builder 读取。Prompt 也可以做成模板 约束片段管理。把坐标系、门洞规则、道具白名单拆成可组合片段不同项目复用。每次修改 Prompt 后最好准备一组固定测试用例跑一遍回归观察输出分布是否变化。8.3 扩展方向GPT-Astra 的最小闭环可以往多个方向扩展生成更大尺度从单一飞船扩展到空间站多个模块拼接玩家通过气闸舱切换区域。生成更细纹理让模型输出墙面颜色、材质关键词代码端映射到 PBR 材质。加入物理引擎使用 Cannon-es 或 Rapier处理复杂碰撞、重力、可开门物体。多人探索把场景 JSON 作为同步数据源浏览器端各自构建同一份场景再叠加玩家坐标同步。流式加载大型场景按区域拆分玩家接近时才构建该区域降低首屏压力。对新手来说不要一上来就追求照片级画面。先把“文字生成 JSON、JSON 构建房间、玩家能走通门洞”这条主线练熟再考虑材质、动画和多房间复杂度。GPT-Astra 这类项目真正的难点从来不是单个模型有多聪明而是生成结果与确定性构建之间能否稳定衔接。把这条衔接整理成 Schema、Prompt、校验、渲染四层后面的扩展都会顺畅很多。