如何绘制一幅《上海外滩》v1.0

上海外滩 v2.0 的黄昏全景

封面:v2.0 的默认机位。黄浦江从画面左侧远处流入,在陆家嘴北侧绕出一个大弯,再向右前方流出;四座地标落在弯道内侧的半岛上。

模型:DeepSeek-flash4.1;harness:Claude Code;能力来源:一组对齐 three.js r186 的 threejs-skills。

这篇文章复盘的是:同一个模型,为什么第一版只搭出「像外滩的元素」,第二版才搭出「能读出外滩的城」。下文把两份提示词称为「第一版」「第二版」,把产出称为「场景 v1」「场景 v2」,避免混淆。

引言:我要的是一座城,不是一张图

我想让模型替我搭一个可交互的 3D 场景,而不是生成一张图。

「可交互」三个字抬高了门槛。一张图只需要在某个角度成立;一个场景要在任意角度、任意时间状态下都成立。几何、材质、光照、后处理、镜头、性能,任何一项掉链子,画面都会立刻露馅。

我用了两版提示词。第一版要到了外滩的元素,第二版才要到外滩的空间。两版之间的差别,不是字数,而是先说什么。

先说结论:决定这类场景成败的,不是模型会不会写 Three.js,而是提示词有没有把空间关系写成可执行、可验收的约束。 skill 负责让 API 用对,提示词负责让世界成立。

一、工具链:13 个 skill,各管一段

我用的不是「一个 three.js 提示词」,而是一组按主题拆开的 skill。它们来自 cloudai-x/threejs-skills,在项目里由 skills-lock.json 锁定版本,安装后通过 .claude/skills 指向 .agents/skills 下的实体目录。

这一组 skill 都对齐 three.js r186,一共 13 个:

分组 skill 管什么
场景骨架 threejs-fundamentals、threejs-geometry 渲染器、相机、动画循环、内置几何、顶点缓冲、实例化
表面 threejs-materials、threejs-textures、threejs-shaders PBR 材质、程序化贴图、自定义 shader
光与画面 threejs-lighting、threejs-postprocessing 灯光、阴影、Bloom 与合成链
运动与交互 threejs-animation、threejs-interaction 关键帧、控制器、拾取
资源与性能 threejs-loaders、threejs-asset-pipeline、threejs-performance GLTF、资源管线、draw call、内存
React 集成 threejs-react-three-fiber R3F 写法

每个 skill 不是一段「帮我写代码」的咒语,而是一份可检索的领域知识:当前版本的 API 长什么样、哪些写法已经过时、性能上该注意什么。比如 threejs-performance 会先要求你读 renderer.info,再谈优化;threejs-geometry 会把 InstancedMesh 和合并几何放在同一个语境里讲。

这带来一个很重要的分工:skill 解决「怎么写对」,提示词解决「写什么」。

Claude Code 在这条链路里负责把判断落到文件系统和浏览器里:读项目、跑 npm run dev、看控制台报错、改文件、再验证。模型负责在上下文里做决策,harness 负责让决策真的运行起来。

二、第一版:一份「物件清单」

第一版提示词是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用 threejs-skills,创建一个高质量、可交互的 Three.js 3D 城市景观场景。

**场景主题:上海外滩(The Bund, Shanghai)**

要求:
1. 还原上海外滩标志性天际线,包括东方明珠、上海中心大厦、环球金融中心、金茂大厦及黄浦江两岸建筑。
2. 构建具有真实空间层次的 3D 城市环境,建筑使用合理的几何结构、材质和灯光。
3. 设置黄浦江、江面反射、游船、道路及城市灯光。
4. 提供白天、黄昏、夜景三种时间状态切换。
5. 支持鼠标旋转、缩放、平移,以及自动环绕镜头动画。
6. 加入适量动态元素,例如游船移动、灯光变化、江面波纹。
7. 使用 PBR 材质、环境光、阴影和后处理,保证视觉质量及运行性能。
8. 优先使用程序化建模,不依赖付费模型资源;必要时可使用公开授权的 GLTF/GLB 模型。
9. 使用 threejs-skills 中相关 Skill,遵循当前 Three.js API 最佳实践。
10. 最终输出完整可运行的 Web 项目,包含启动说明。

**验收标准:** 场景具有明显的上海外滩辨识度,具有真实的 3D 空间感,交互流畅,支持桌面浏览器运行。

这份提示词写得不算差。它把「一个高质量 Three.js 城市景观」拆成了可以逐条勾选的要求。模型也逐条交付了:四座地标、反射水面、游船、车流、昼夜切换、Bloom、轨道控制器,一个都不缺。

问题出在条目之间没有关系。

河道被实现成 width = 420、length = 7600 的一条直江,水面是一张 PlaneGeometry(430, 7600, 1, 1) 的长方形;两岸是两块 PlaneGeometry(3800, 9000) 的大地,建筑按世界坐标摆上去。默认机位高 285 米,看向 200 米高的目标,视角更接近滨江平视。

于是画面里出现了三个症状:

  1. 没有骨架。 黄浦江是一条直的水带,不是一条河。外滩之所以是外滩,首先因为江水在那里拐了一个弯。
  2. 建筑是孤岛。 每栋楼都按坐标放对了位置,但楼与楼、楼与岸线之间没有生成关系。外滩建筑群像一排摆好的模型,而不是沿着岸线长出来的城市。
  3. 镜头替观众做了选择。 平视镜头能看清单栋楼和滨江步道,但看不到城市形态。读者第一眼看到的是「一排楼」,不是「一座城」。

这不是模型偷懒。提示词只说了「设置黄浦江」,没有说这条江怎么弯、陆家嘴在弯的哪一侧、镜头从哪里看。信息缺失时,模型只能选最省力的补全方式:直线、矩形、平视。它在每个局部都做对了,却在整体上做错了。

三、第二版:把地理写进第一节

第二版提示词换了一个开头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
使用 threejs-skills,创建一个高品质、具有真实地理空间关系的上海外滩 3D 城市景观。

**核心目标:高度还原参考图中的上海黄浦江大尺度 U 形蜿蜒河道,以及陆家嘴被河流环绕的城市空间形态。**

### 1. 地理与空间布局(最重要)
- 黄浦江必须呈现明显、连续、自然的大尺度 U 形弯曲,而不是直线河道或普通弧形滨江。
- 河道从画面左侧远处延伸,向中央区域大幅弯曲,再绕过陆家嘴区域向右侧远处延伸,形成具有包围感的马蹄形水域轮廓。
- 河流两岸自然分布城市建筑、道路、码头和滨江景观。
- 陆家嘴位于 U 形河道内侧的突出城市区域,呈现半岛般的空间视觉效果。
- 河道宽度、弯曲半径、岸线轮廓应具有真实城市地理尺度,避免规则几何弧线。
- 城市建筑沿河岸自然延伸,不能形成孤立的建筑模型集合。

### 2. 标志性建筑
重点还原陆家嘴天际线:
- 东方明珠电视塔
- 上海中心大厦
- 上海环球金融中心
- 金茂大厦

建筑集中分布在河道弯曲区域内侧,保持合理的高度比例、空间距离和地标辨识度。两岸补充密集的城市建筑群。

### 3. 场景与视觉效果
- 黄浦江采用具有真实反射效果的水面材质。
- 加入游船、货轮及其动态航行轨迹。
- 沿岸布置道路、桥梁、码头和城市灯光。
- 使用 PBR 材质、HDR 环境光、阴影和适当的 Bloom 后处理。
- 默认采用日落至蓝调时刻的光照氛围,天空呈现橙粉色与蓝灰色渐变,城市灯光逐渐亮起。

### 4. 镜头与构图
- 使用高空斜俯视的广角摄影机位,参考真实城市航拍照片。
- 镜头高度足够高,确保完整展示黄浦江 U 形弯道及两岸城市空间。
- 河道弯曲轮廓必须成为画面的核心视觉主体。
- 陆家嘴地标建筑群位于画面中央偏后位置。
- 不采用地面平视视角,不要只展示单栋建筑或局部滨江景观。

### 5. 交互与动画
- 支持鼠标旋转、缩放、平移。
- 提供自动环绕镜头展示。
- 支持白天、黄昏、夜景切换。
- 游船沿河道连续移动。
- 江面具有动态波纹和环境反射。

### 6. 技术要求
- 使用 threejs-skills 中相关 Skill。
- 使用 Three.js + WebGL,优先程序化建模。
- 采用合理的几何分段和曲线控制构建自然河岸及河道。
- 使用 CatmullRomCurve3 等曲线技术构建河流中心线和岸线,再生成连续水面网格。
- 建筑、道路、河岸、水面分别建模,保持空间关系一致。
- 优化 Draw Calls、阴影和粒子数量,保证浏览器流畅运行。

**最终验收标准:**
第一眼能够识别出上海陆家嘴和黄浦江;从整体俯视视角能够清晰看到黄浦江环绕陆家嘴的大尺度 U 形蜿蜒轮廓。河流必须具有真实自然的曲折效果,而不是一条直线或简单圆弧。

输出完整可运行的 Web 项目,并提供启动说明。

相比第一版,真正的改动只有四处,但每一处都改变了模型的行为。

第一,把地理放到第一节,并标注「最重要」。 提示词里的顺序就是模型的注意力顺序。第一版把「地标」放在第 1 条,模型就先堆地标,再补河;第二版先定义河,地标只是河湾内侧的产物。

第二,把形容词换成可执行的几何。 「U 形」「从画面左侧远处延伸」「向中央区域大幅弯曲」「绕过陆家嘴区域向右侧远处延伸」「马蹄形」「真实城市地理尺度」——这些描述可以直接变成一条曲线上的控制点和一段宽度函数(实现时河宽落在 390–620 米之间),而不是一句「要有外滩的感觉」。

第三,给出实现路径。 「使用 CatmullRomCurve3 等曲线技术构建河流中心线和岸线,再生成连续水面网格」「建筑、道路、河岸、水面分别建模,保持空间关系一致」。这不是替模型写代码,而是告诉它:用一条曲线作为所有沿江构件的共同基准。

第四,写禁止项和验收标准。 「而不是直线河道或普通弧形滨江」「不采用地面平视视角」「不要只展示单栋建筑或局部滨江景观」——这三句排除错误解;「第一眼能够识别出上海陆家嘴和黄浦江」「从整体俯视视角能够清晰看到……U 形蜿蜒轮廓」——这两句给出可以被眼睛检查的通过条件。

这三层合起来,才是第二版真正有效的原因:约束可执行,结果可验收,优先级明确。

四、落到代码:河道成为唯一空间基准

提示词里那句「用 CatmullRomCurve3 构建中心线」,最后落成了一个 src/world/river.js。它只做一件事:把整条黄浦江变成一套可以查询的空间坐标。

1
2
3
4
5
6
7
8
const curve = new THREE.CatmullRomCurve3(
RIVER.controlPoints.map(([x, z]) => new THREE.Vector3(x, 0, z)),
false,
"centripetal",
);

const spaced = curve.getSpacedPoints(900);
const length = curve.getLength();

29 个控制点定义了河道的走向,按弧长均匀采样 900 段,得到每个采样点的位置、切线、法线和半宽。这里用 centripetal 参数化,是为了减少急弯处的过冲——河道弯得越急,普通 Catmull-Rom 越容易在控制点之间甩出一段不自然的弧线。

紧接着是一段容易被忽略、但不写就会出事的逻辑:

1
2
const radius = angle > 1e-5 ? arc / angle : Infinity;
samples[i].halfWidth = Math.min(samples[i].halfWidth, radius * 0.62);

岸线不是单独画的,而是中心线向两侧偏移「半宽」得到的。弯太急、河太宽,偏移后的内岸会自己穿过自己。所以急弯处必须收窄河宽,0.62 是给偏移留的安全余量;之后再跑两遍平滑,避免宽度在曲率突变处出现折角。

有了这套采样,river 对外只暴露几个查询:

1
2
3
4
5
river.pointAt(u);              // 弧长参数 u ∈ [0,1] 上的点
river.tangentAt(u); // 该点的切线方向
river.bankPoint(u, side); // 岸线取点,+1 为浦西/外滩,-1 为浦东/陆家嘴
river.lanePoint(u, offset); // 江面航线取点
river.nearest(x, z); // 陆地 / 水面判定

然后,所有沿江构件都从这里取点:

  • 水面沿着中心线生成连续 ribbon,而不是一张矩形平面;边缘带 aEdge 属性,用来画贴岸泡沫。
  • 陆地是一整块 ShapeGeometry,河道作为 Path 孔洞挖掉,避免水面和地面互相穿插。
  • 岸墙、步道、滨江道路用 createRibbonGeometry / createBankWallGeometry 沿河生成。
  • 外滩 16 栋历史建筑沿着浦西一侧的 bankPoint 逐栋排布,并朝向江面。
  • 游船和货轮沿着中心线偏移出的航线航行,用 tangentAt 自动跟随弯道转向。

这套结构的价值不在「代码更优雅」,而在改一个地方,全场景跟着变。修改一个控制点,水面、岸线、道路、步道、建筑、船只航线会同时移动;而在场景 v1 里,想挪一下河,得分别改水面、岸墙、道路和建筑。

提示词把「河道是基准」说清楚之后,代码自然长成了「一个中枢 + 多个消费者」。这是我觉得这次迭代里最值得记下来的一点:好的提示词不只描述结果,还会暗示结构。

五、两版放在一起看

场景 v1 场景 v2
河道 沿 Z 轴的直线河道 CatmullRomCurve3 中心线生成的大尺度 U 形弯道
河岸 两条直线岸墙 沿曲线偏移生成、宽度随河道变化的连续岸线
水面 矩形平面 沿河道的连续 ribbon 网格,岸边泡沫随岸线变化
陆地 两块大平面 带河道孔洞的连续地块,道路、步道、建筑共享河岸坐标
城市 以直线路网布置 建筑、道路、车辆、树木、路灯、码头都沿河岸曲线延伸
船 沿 Z 轴往返 沿河道中心线的不同航线连续航行,自动跟随弯道转弯
镜头 平直滨江视角 高空斜俯视,完整展示 U 形江湾与半岛
新增 — 货轮、两座跨江桥、码头上盖、远景城市、城市底图纹理

场景 v1 的预览:直线河道与两块大平面

图 1:场景 v1。元素齐全,但河道是一条直的水带,城市沿两条直线岸线铺开——第一眼看到的是「一排楼」,不是一座城。

场景 v2 的夜景

图 2:场景 v2 的夜景。同一条江被重新定义成一条曲线,陆家嘴三面环水,建筑、道路、船只都沿着这条曲线展开;昼夜系统在 1.9 秒内统一插值天空、太阳、雾、曝光、泛光和所有窗灯,水面参数也参与过渡。

场景 v2 做完之后,同一套场景还被用来导出了一段 28 秒的影片:四个固定镜头从 U 形江湾俯冲进入外滩立面,再环绕陆家嘴四塔,在镜头内完成黄昏到夜景的过渡。下面这张联系表是其中的六帧:

场景 v2 影片模式的六帧关键画面

图 3:v2.0 影片模式的六帧联系表(01-opening → 06-finale)。固定时间线让相机、昼夜和字幕都由绝对时间推导,因此每次导出的帧序列一致。

六、把这次迭代抽成六条写法

如果下次还要让模型搭一个「有地理关系」的场景,我会按这六条写提示词。

1. 先空间,后物件,再氛围。
先定义场景的骨架——哪条线决定其他一切;再列地标和物件;最后谈材质、灯光、后处理。顺序不是排版偏好,而是模型的注意力分配。

2. 把「像」翻译成可验证的关系。
「要有外滩的感觉」无法执行;「河道从左侧远处流入,绕过陆家嘴,再向右远处流出,形成马蹄形」可以。能变成控制点、宽度函数、相机坐标的描述,才是有效描述。

3. 给实现路径,但不锁死细节。
「用 CatmullRomCurve3 构建中心线和岸线」是在指定结构;「用 29 个控制点、900 段采样、三条正弦叠加河宽」就是过度干预。前者让模型少走弯路,后者会让它把注意力花在满足数字上。

4. 写禁止项。
「不要直线」「不要地面平视」「不要孤立建筑」比正面描述更能排除错误解。模型在信息不足时会选最省力的默认值,禁止项就是提前堵住那些默认值。

5. 验收标准要能被眼睛检查。
「第一眼能识别出陆家嘴和黄浦江」「俯视能看到 U 形轮廓」——这类标准可以在浏览器里直接判断。不要写「视觉质量高」这种说了等于没说的句子。

6. 一次只改一个维度。
第二版把主要精力放在空间关系上,材质、光照和后期沿用同一套方案。这样两版之间的差异才能归因:画面变好了,是因为河变了,不是因为同时加了十盏灯。

还有一条元规则:让 skill 管 API,让提示词管意图。 不要用提示词去重复 skill 里已经写好的 Three.js 用法;把提示词的篇幅留给「这个世界应该长什么样」。

七、边界:提示词能走多远

场景 v2 仍然是风格化还原,不是测绘。

四座地标是参数化近似:东方明珠的三根立柱和三个球体、上海中心的 120° 扭转三叶截面、环球金融中心的「开瓶器」顶部、金茂大厦的逐层收分,都是从真实形态里抽出来的几何特征,不是实测模型。河宽是正弦叠加出来的,岸线是曲线偏移出来的,不是从地理数据里读出来的。

再往前一步,问题就换了。第三版提示词要的是 1:1 真实地理:真实岸线、真实建筑位置和高度、统一坐标参考系、OSM/GIS/Cesium/Blender、以米为单位的世界比例,还要求「缺少真实数据时先报告缺失项,不得用虚构数据冒充真实城市模型」。这次工作暂时停在「数据源调研与坐标系统」这一步,没有继续往下做。

这不是提示词写坏了,而是提示词能提高上限,但不能替代数据。当目标从「像外滩」变成「是外滩」时,主要的工程量在数据获取、坐标转换、缺失值标注和精度验收上,不在措辞上。

还有一层边界是视觉终检。构建可以通过、产物可以非空、图片可以被解析,但「像不像外滩」最终仍然要人眼看。工具能保证场景跑起来,不能保证它好看。

八、复现

场景 v1 和 v2 是两个相互独立的 Vite 项目,各自带 package.json 和构建配置。环境要求 Node.js ≥ 20.19,以及支持 WebGL 2 的桌面浏览器。

1
2
3
4
5
6
cd material/上海外滩v2.0

npm install
npm run dev # 默认 http://localhost:5173
npm run build # 产物在 dist/,使用相对路径,可部署到任意子目录
npm run preview # 本地预览构建产物

交互方式:

操作 效果
左键拖拽 旋转视角
滚轮 / 双指 缩放
右键拖拽 / 双指拖动 平移
右上角地标列表 飞行到东方明珠、上海中心、环球金融中心、金茂大厦、外滩建筑群
底部时间切换 白天 / 黄昏 / 夜景(1.9 秒平滑过渡)
自动环绕 开启或关闭自动环绕镜头
键盘 1 2 3 切换时间
键盘 Space 自动环绕开关
键盘 R 重置视角

源码在 open-wuxu/unreal-factory。material/上海外滩v1.0/ 是直线河道的存档版本,material/上海外滩v2.0/ 是当前主版本。

结语:先找到那条「河道」

「绘制」这个词有点误导。我们画的不是一张图,是一组关系:河道决定岸线,岸线决定道路和建筑,建筑决定天际线,镜头决定读者第一眼看到什么。

第一版提示词列全了物件,却没有定义关系,所以模型只能把它们并排放在一起;第二版只多说了几句关于河的话,整座城就跟着河长了出来。

下次让模型搭场景,先别急着列地标。先问自己:这个场景的「河道」是什么?哪一条线一旦定了,其他东西都得跟着它走?

把那条线写进第一节。

引用与致谢

  • threejs-skills:cloudai-x/threejs-skills,13 个按主题拆分的 Three.js skill,对齐 three.js r186。
  • Three.js:MIT 许可。
  • 场景源码与截图:open-wuxu/unreal-factory。文中四张图片均由该项目的场景 v1 / v2 生成;图 3 的联系表来自 v2.0 的影片模式。
  • 模型与 harness:DeepSeek-flash4.1 在 Claude Code 中运行。

感谢阅读。