适用场景
这套方案面向想把“大模型生成能力”落成一个能点、能转、能看的可视化页面的人:手里有国产算力机器(或只有一台普通笔记本),拿到 Spark-X2.6 的接口权限,希望走通“模型出场景数据 → 程序化建模 → 浏览器实时渲染 → 静态托管上线”这条完整链路。产出的东西不是一张渲染图,而是一个可交互的 3D 故宫场景:鼠标拖拽环视、滚轮缩放、点击建筑弹出名称说明。整个流程不依赖任何商业建模软件,全部由代码生成,改一行提示词就能换一套布局。
环境与前置条件
一个关键认知先讲清楚:这类“文生 3D 场景”的常见做法,是让模型输出结构化的场景描述(JSON),再由代码把 JSON 变成几何体。模型本身不直接吐 .obj/.glb 文件。所以算力消耗分两块——模型推理(走云端 API 时可以忽略)和渲染(在浏览器端跑,靠本机显卡)。下面两条路径都覆盖。
| 项目 | 建议 |
|---|---|
| 操作系统 | Linux(Ubuntu 22.04 及以上等主流发行版)或 macOS;Windows 建议用 WSL2 |
| Python | 3.10 及以上 |
| 浏览器 | 支持 WebGL2 的现代浏览器(Chrome / Edge / Firefox 的较新版本) |
| 内存 | 调用云端 API 时 4 GB 起;本地跑推理建议 32 GB 起 |
| 显存 / NPU 内存 | 本地推理按官方模型卡要求准备,以官方文档当前版本为准 |
| 磁盘 | 20 GB 空闲(存放模型权重、前端依赖、日志) |
| 网络 | 能访问模型服务的 API 域名与前端 CDN;内网环境需提前放通 |
国产算力的驱动检查(本地推理才需要;纯 API 调用可跳过)。以昇腾为例,安装官方驱动与 CANN 工具包后,验证:
```bash
npu-smi info
python -c "import torch, torch_npu; print(torch.npu.is_available(), torch.npu.device_count())"
```
预期输出 True 1(或更多卡数)。驱动、CANN、torch_npu 三者版本必须配套,具体对应关系以官方文档当前版本为准,装错版本是最常见的坑。其他国产卡(海光 DCU、寒武纪等)同理,装对应厂商的 torch 插件,验证脚本换成厂商提供的设备检测命令即可。
分步骤部署
第 1 步:领取免费额度并创建 API Key
登录 Spark-X2.6 的官方平台,完成注册与实名认证,在控制台找到“体验额度 / 免费额度”入口领取(多数平台对新账号有一次性赠送额度,具体额度与有效期以官方页面为准)。随后进入“API Key”页面新建一个密钥,复制后立刻存进密码管理器——多数平台的密钥只在创建时完整显示一次。
在控制台里顺手确认三件事:可用的模型名称、API 的 Base URL、以及是否兼容 OpenAI 的 /chat/completions 协议。这三项都记下来,下一步要用。
第 2 步:配置本地环境变量
密钥不要写进代码。新建一个 .env 文件(记得加进 .gitignore):
```bash
export SPARK_BASE_URL="<控制台给出的 API 地址,不要带结尾斜杠>"
export SPARK_API_KEY="<你的 API Key>"
export SPARK_MODEL="<控制台给出的模型名称,以官方文档当前版本为准>"
```
加载并验证:
```bash
source .env
echo "${SPARK_BASE_URL:?未设置}" && echo "${SPARK_API_KEY:0:6}******"
```
看到 Base URL 和打码后的 key 前缀,说明变量已生效。
第 3 步:建项目骨架并装依赖
```bash
mkdir -p gugong/{scripts,web}
cd gugong
python -m venv .venv && source .venv/bin/activate
pip install requests
```
目录约定:scripts/ 放生成脚本,web/ 放前端静态文件,最终只要把 web/ 整个目录丢到服务器上就能跑。
第 4 步:写场景生成的提示词与调用脚本
新建 scripts/generate_scene.py。核心思路是把“故宫”这件事拆成模型能稳定输出的字段:建筑类型、位置、尺寸、屋顶形式、颜色。字段越具体,模型返回的 JSON 越不容易跑偏。
```python
import json, os, re, sys, time
import requests
BASE_URL = os.environ["SPARK_BASE_URL"].rstrip("/")
API_KEY = os.environ["SPARK_API_KEY"]
MODEL = os.environ["SPARK_MODEL"]
SYSTEM_PROMPT = "你是三维场景数据生成器。只输出 JSON 对象,不要解释,不要 Markdown 代码块。"
USER_PROMPT = """生成一个可用于实时渲染的“故宫太和殿广场”三维场景,输出严格符合以下 JSON 结构:
{
"name": "字符串",
"ground": {"size": [宽, 深], "color": "#RRGGBB"},
"buildings": [
{
"id": "唯一英文小写标识",
"label": "中文名称",
"type": "hall | gate | pavilion | wall",
"position": [x, 0, z],
"size": [宽, 高, 深],
"storeys": 1,
"wallColor": "#RRGGBB",
"roof": {"type": "hip | gable | flat", "color": "#RRGGBB", "height": 数值}
}
]
}
约束:
1. 单位统一为米,右手坐标系,Y 轴向上,地面为 XZ 平面,原点在广场中心。
2. 至少包含:1 座主殿、2 座东西配殿、1 座门楼、4 段围墙。
3. 所有建筑不得互相穿插,坐标范围控制在 -120 到 120 之间。
4. 屋顶用传统宫殿的黄琉璃瓦色系,墙体用朱红色系。
5. 只输出 JSON,不要任何额外文字。"""
def extract_json(text: str) -> dict:
text = text.strip()
fenced = re.search(r"``(?:json)?\s*(.*?)``", text, re.S)
if fenced:
text = fenced.group(1).strip()
start, end = text.find("{"), text.rfind("}")
if start == -1 or end == -1:
raise ValueError("响应里找不到 JSON 对象")
return json.loads(text[start:end + 1])
def call_model() -> str:
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_PROMPT},
],
"temperature": 0.3,
}
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
resp = requests.post(f"{BASE_URL}/chat/completions",
headers=headers, json=payload, timeout=180)
if resp.status_code == 429:
raise RuntimeError("RATE_LIMIT")
resp.raise_for_status()
data = resp.json()
return data["choices"][0]["message"]["content"]
def main():
for attempt in range(3):
try:
raw = call_model()
scene = extract_json(raw)
with open("web/scene.json", "w", encoding="utf-8") as f:
json.dump(scene, f, ensure_ascii=False, indent=2)
print(f"OK 生成建筑 {len(scene['buildings'])} 座 -> web/scene.json")
return
except RuntimeError as e:
if "RATE_LIMIT" in str(e):
wait = 5 * (2 ** attempt)
print(f"触发限流,{wait}s 后重试…", file=sys.stderr)
time.sleep(wait)
continue
raise
except (ValueError, json.JSONDecodeError) as e:
print(f"第 {attempt + 1} 次解析失败:{e}", file=sys.stderr)
time.sleep(2)
sys.exit("三次尝试均失败,请检查提示词或额度是否用尽")
if __name__ == "__main__":
main()
```
运行:
```bash
python scripts/generate_scene.py
```
成功标志是终端打印 OK 生成建筑 N 座 -> web/scene.json,且 web/scene.json 能被正常打开、字段齐全。如果某个字段模型总是写错,别急着改代码,先在提示词里补一条约束,通常比在代码里做兼容更省事。
第 5 步:写前端渲染页面
新建 web/index.html。几何体全部用代码拼:墙体是盒子,屋顶是四棱锥(圆锥体取四段并旋转 45°)。这样零外部模型文件,加载快、也方便程序化调参。
```html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>可交互 3D 故宫</title>
<style>
html, body { margin: 0; height: 100%; overflow: hidden; background: #0e1116; }
#app { width: 100%; height: 100%; display: block; }
#info {
position: fixed; left: 16px; top: 16px; padding: 10px 14px;
background: rgba(20,24,32,.78); color: #e8eaf0; border-radius: 8px;
font: 14px/1.6 system-ui, sans-serif; pointer-events: none;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@<VERSION>/build/three.module.js",
"three/addons/": "https://unpkg.com/three@<VERSION>/examples/jsm/"
}
}
</script>
</head>
<body>
<canvas id="app"></canvas>
<div id="info">拖拽旋转 · 滚轮缩放 · 点击建筑查看名称</div>
<script type="module" src="./app.js"></script>
</body>
</html>
```
<VERSION> 替换成官网当前推荐的版本号,以官方文档当前版本为准;内网环境把这两个文件下载到本地 vendor/ 目录再改 importmap 指向本地路径。
新建 web/app.js:
```js
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const canvas = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
const scene = new THREE.Scene();
scene.background = new THREE.Color('#9fb6cf');
scene.fog = new THREE.Fog('#9fb6cf', 260, 620);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.5, 2000);
camera.position.set(90, 70, 130);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 10, 0);
controls.maxPolarAngle = Math.PI / 2.05;
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight('#ffffff', '#6b6252', 1.1));
const sun = new THREE.DirectionalLight('#fff2d8', 1.6);
sun.position.set(120, 180, 90);
scene.add(sun);
function makeHipRoof(w, d, h, color) {
const r = w / Math.SQRT2;
const geo = new THREE.ConeGeometry(r, h, 4, 1);
geo.rotateY(Math.PI / 4);
geo.scale(1, 1, d / w);
const mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ color, roughness: .85 }));
mesh.position.y = h / 2;
return mesh;
}
function makeBuilding(b) {
const [w, hgt, d] = b.size;
const group = new THREE.Group();
const body = new THREE.Mesh(
new THREE.BoxGeometry(w, hgt, d),
new THREE.MeshStandardMaterial({ color: b.wallColor, roughness: .9 })
);
body.position.y = hgt / 2;
group.add(body);
if (b.roof && b.roof.type !== 'flat') {
const roof = makeHipRoof(w * 1.12, d * 1.12, b.roof.height ?? hgt * 0.4, b.roof.color);
roof.position.y = hgt;
group.add(roof);
}
group.position.set(b.position[0], 0, b.position[2]);
group.userData = { label: b.label, id: b.id };
return group;
}
const res = await fetch('./scene.json');
const data = await res.json();
const [gw, gd] = data.ground.size;
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(gw, gd),
new THREE.MeshStandardMaterial({ color: data.ground.color, roughness: 1 })
);
ground.rotation.x = -Math.PI / 2;
scene.add(ground);
const pickables = [];
for (const b of data.buildings) {
const g = makeBuilding(b);
g.traverse(o => { if (o.isMesh) { o.userData.root = g; pickables.push(o); } });
scene.add(g);
}
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let downAt = null;
canvas.addEventListener('pointerdown', e => { downAt = { x: e.clientX, y: e.clientY }; });
canvas.addEventListener('pointerup', e => {
if (!downAt) return;
const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);
downAt = null;
if (moved > 5) return; // 拖拽不算点击
pointer.x = (e.clientX / innerWidth) * 2 - 1;
pointer.y = -(e.clientY / innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hit = raycaster.intersectObjects(pickables, false)[0];
document.getElementById('info').textContent =
hit ? 当前选中:${hit.object.userData.root.userData.label}
: '拖拽旋转 · 滚轮缩放 · 点击建筑查看名称';
});
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
```
几个要点:拾取时用 pointerdown/pointerup 的位移差区分“点击”和“拖动”,否则一转镜头就误触发弹窗;每个 mesh 上挂 root 引用,点击到屋顶也能取到整体建筑的名称。
第 6 步:本地起服务预览
浏览器对 file:// 下的 fetch 有限制,必须走 HTTP:
```bash
cd web && python -m http.server 8080
```
浏览器打开 http://127.0.0.1:8080/。
第 7 步:用 Nginx 托管并反代 API
生产环境不要让浏览器直接持有 API Key。把生成脚本包成一个内部接口(示例用 FastAPI 或任何你顺手的小框架),再由 Nginx 反代;前端只请求同域的 /api/。同时给 .glb/.json 明确 MIME,避免加载被拦。
```nginx
server {
listen 80;
server_name _;
root /var/www/gugong;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
}
location ~* \.(glb|gltf)$ {
default_type application/octet-stream;
}
location ~* \.(json|js|css)$ {
add_header Cache-Control "public, max-age=300";
}
gzip on;
gzip_types application/json application/javascript text/css;
}
```
```bash
sudo mkdir -p /var/www/gugong && sudo cp -r web/* /var/www/gugong/
sudo nginx -t && sudo systemctl reload nginx
```
nginx -t 输出 syntax is ok 与 test is successful 即配置通过。
验证部署是否成功
接口连通性
```bash
curl -s -X POST "$SPARK_BASE_URL/chat/completions" \
-H "Authorization: Bearer $SPARK_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$SPARK_MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"回复两个字:收到\"}]}" \
| head -c 300
```
预期返回含 choices 数组的 JSON。返回 401/403 说明密钥或额度有问题,先查这一步再看后面。
产物结构完整性
```bash
python - <<'PY'
import json
d = json.load(open("web/scene.json"))
assert "buildings" in d and len(d["buildings"]) >= 6, "建筑数量不足"
need = {"id", "label", "type", "position", "size", "wallColor", "roof"}
for b in d["buildings"]:
missing = need - set(b)
assert not missing, f"{b.get('id')} 缺字段 {missing}"
print("scene.json 校验通过,共", len(d["buildings"]), "座建筑")
PY
```
预期打印 scene.json 校验通过,共 N 座建筑。
页面与渲染
```bash
curl -sI http://127.0.0.1:8080/scene.json | head -n 1
curl -sI http://127.0.0.1:8080/app.js | head -n 1
```
预期两行都是 HTTP/1.1 200 OK。再用浏览器打开页面,按 F12 看 Console 应为空(无红色报错),拖动画面能转、滚轮能缩放、点主殿左上角信息条变成“当前选中:太和殿”。在 Console 里执行 renderer 不可见时,改为观察 Network 面板:scene.json 状态 200、大小与文件一致即可。
常见报错与解决
1. 401 Unauthorized / invalid api key
原因:环境变量没加载、密钥复制时带了空格或换行、密钥被删除或重置。
```bash
source .env
echo -n "$SPARK_API_KEY" | wc -c # 与平台显示的密钥长度比对
curl -s -o /dev/null -w "%{http_code}\n" "$SPARK_BASE_URL/models" -H "Authorization: Bearer $SPARK_API_KEY"
```
返回 200 说明密钥有效;仍是 401 就到控制台重新生成一个。
2. 429 Too Many Requests 或提示免费额度已用尽
原因:短时间请求过密,或赠送额度耗完。脚本里已经带了指数退避;额度问题只能去控制台查看剩余量并按其指引处理,具体规则以官方页面为准。
```bash
降低调用频率:把脚本中的 time.sleep 调大,或把 temperature 固定后复用同一份 scene.json
python scripts/generate_scene.py
```
3. json.decoder.JSONDecodeError: Expecting value
原因:模型在 JSON 前后加了说明文字,或字段里用了中文全角引号。extract_json 已处理代码块包裹,仍有问题时把提示词里的“只输出 JSON”再强调一次,并把 temperature 降到 0.1~0.3。
```bash
python -c "import json;json.load(open('web/scene.json'));print('ok')"
```
4. RuntimeError: No NPU device found / torch.npu.is_available() 为 False
原因:驱动、CANN、torch_npu 版本不配套,或当前用户没有设备访问权限。
```bash
npu-smi info # 驱动层是否识别到卡
ls -l /dev/davinci* # 设备节点是否存在
python -c "import torch, torch_npu; print(torch.__version__, torch_npu.__version__)"
```
版本对应关系以官方文档当前版本为准;把当前用户加入设备所属用户组后重新登录再试。
5. 页面空白,Console 报 Failed to load module script: MIME type
原因:Nginx 把 .js 当成了纯文本,或 importmap 里的 CDN 地址不可达。
```bash
curl -sI http://your-host/app.js | grep -i content-type # 应为 application/javascript
```
修正 Nginx 的 mime.types 引用并 systemctl reload nginx;内网环境改为本地 vendor/ 目录引入 Three.js。
6. blocked by CORS policy
原因:前端页面与 API 不同源,浏览器拦截。用第 7 步的 /api/ 反向代理走同源,或在 API 服务侧显式允许页面域名。
后续维护
备份:需要长期保留的只有三样——web/scene.json(生成结果)、scripts/(提示词与调用逻辑)、Nginx 站点配置。用 cron 每天打包一次、保留最近 14 天即可:
```bash
tar -czf /backup/gugong-$(date +%F).tar.gz web/scene.json scripts /etc/nginx/conf.d/gugong.conf
find /backup -name 'gugong-*.tar.gz' -mtime +14 -delete
```
升级:分三层看。模型升级时只改 SPARK_MODEL 环境变量,用同一份提示词重跑一次,对比新旧 scene.json 的建筑数量与坐标合理性;前端升级 Three.js 时先改 importmap 里的版本号,在测试环境跑一遍再切生产,注意新版 API 可能有破坏性变更;系统层升级驱动与推理框架时,务必三个组件成套升级并先在备用机器验证。
日志与监控:生成脚本把每次的请求耗时、状态码、重试次数写进结构化日志(JSON 行格式最省事),方便 grep 统计失败率;Nginx 打开 access_log 与 error_log,重点关注 5xx 与 404 比例。可用性监控建议至少覆盖两个探针:GET / 返回 200、GET /scene.json 能解析出 buildings 字段。额度侧也要盯——免费额度是会被耗尽的,在控制台设置余额提醒,或在自己服务里对每日调用次数设上限,避免某天页面上线了但场景刷不出来。
成本与体验的小优化:场景 JSON 生成一次就可以固定下来,日常访问不再消耗推理额度,把它当作“构建产物”纳入版本管理;只有在需要换布局、换季节配色时重新生成。前端可以顺手做个降级:WebGL2 不可用时给出静态预览图,比白屏友好得多。
