如何绘制一幅《上海外滩》v1.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 | 使用 threejs-skills,创建一个高质量、可交互的 Three.js 3D 城市景观场景。 |
这份提示词写得不算差。它把「一个高质量 Three.js 城市景观」拆成了可以逐条勾选的要求。模型也逐条交付了:四座地标、反射水面、游船、车流、昼夜切换、Bloom、轨道控制器,一个都不缺。
问题出在条目之间没有关系。
河道被实现成 width = 420、length = 7600 的一条直江,水面是一张 PlaneGeometry(430, 7600, 1, 1) 的长方形;两岸是两块 PlaneGeometry(3800, 9000) 的大地,建筑按世界坐标摆上去。默认机位高 285 米,看向 200 米高的目标,视角更接近滨江平视。
于是画面里出现了三个症状:
- 没有骨架。 黄浦江是一条直的水带,不是一条河。外滩之所以是外滩,首先因为江水在那里拐了一个弯。
- 建筑是孤岛。 每栋楼都按坐标放对了位置,但楼与楼、楼与岸线之间没有生成关系。外滩建筑群像一排摆好的模型,而不是沿着岸线长出来的城市。
- 镜头替观众做了选择。 平视镜头能看清单栋楼和滨江步道,但看不到城市形态。读者第一眼看到的是「一排楼」,不是「一座城」。
这不是模型偷懒。提示词只说了「设置黄浦江」,没有说这条江怎么弯、陆家嘴在弯的哪一侧、镜头从哪里看。信息缺失时,模型只能选最省力的补全方式:直线、矩形、平视。它在每个局部都做对了,却在整体上做错了。
三、第二版:把地理写进第一节
第二版提示词换了一个开头:
1 | 使用 threejs-skills,创建一个高品质、具有真实地理空间关系的上海外滩 3D 城市景观。 |
相比第一版,真正的改动只有四处,但每一处都改变了模型的行为。
第一,把地理放到第一节,并标注「最重要」。 提示词里的顺序就是模型的注意力顺序。第一版把「地标」放在第 1 条,模型就先堆地标,再补河;第二版先定义河,地标只是河湾内侧的产物。
第二,把形容词换成可执行的几何。 「U 形」「从画面左侧远处延伸」「向中央区域大幅弯曲」「绕过陆家嘴区域向右侧远处延伸」「马蹄形」「真实城市地理尺度」——这些描述可以直接变成一条曲线上的控制点和一段宽度函数(实现时河宽落在 390–620 米之间),而不是一句「要有外滩的感觉」。
第三,给出实现路径。 「使用 CatmullRomCurve3 等曲线技术构建河流中心线和岸线,再生成连续水面网格」「建筑、道路、河岸、水面分别建模,保持空间关系一致」。这不是替模型写代码,而是告诉它:用一条曲线作为所有沿江构件的共同基准。
第四,写禁止项和验收标准。 「而不是直线河道或普通弧形滨江」「不采用地面平视视角」「不要只展示单栋建筑或局部滨江景观」——这三句排除错误解;「第一眼能够识别出上海陆家嘴和黄浦江」「从整体俯视视角能够清晰看到……U 形蜿蜒轮廓」——这两句给出可以被眼睛检查的通过条件。
这三层合起来,才是第二版真正有效的原因:约束可执行,结果可验收,优先级明确。
四、落到代码:河道成为唯一空间基准
提示词里那句「用 CatmullRomCurve3 构建中心线」,最后落成了一个 src/world/river.js。它只做一件事:把整条黄浦江变成一套可以查询的空间坐标。
1 | const curve = new THREE.CatmullRomCurve3( |
29 个控制点定义了河道的走向,按弧长均匀采样 900 段,得到每个采样点的位置、切线、法线和半宽。这里用 centripetal 参数化,是为了减少急弯处的过冲——河道弯得越急,普通 Catmull-Rom 越容易在控制点之间甩出一段不自然的弧线。
紧接着是一段容易被忽略、但不写就会出事的逻辑:
1 | const radius = angle > 1e-5 ? arc / angle : Infinity; |
岸线不是单独画的,而是中心线向两侧偏移「半宽」得到的。弯太急、河太宽,偏移后的内岸会自己穿过自己。所以急弯处必须收窄河宽,0.62 是给偏移留的安全余量;之后再跑两遍平滑,避免宽度在曲率突变处出现折角。
有了这套采样,river 对外只暴露几个查询:
1 | river.pointAt(u); // 弧长参数 u ∈ [0,1] 上的点 |
然后,所有沿江构件都从这里取点:
- 水面沿着中心线生成连续 ribbon,而不是一张矩形平面;边缘带
aEdge属性,用来画贴岸泡沫。 - 陆地是一整块
ShapeGeometry,河道作为Path孔洞挖掉,避免水面和地面互相穿插。 - 岸墙、步道、滨江道路用
createRibbonGeometry/createBankWallGeometry沿河生成。 - 外滩 16 栋历史建筑沿着浦西一侧的
bankPoint逐栋排布,并朝向江面。 - 游船和货轮沿着中心线偏移出的航线航行,用
tangentAt自动跟随弯道转向。
这套结构的价值不在「代码更优雅」,而在改一个地方,全场景跟着变。修改一个控制点,水面、岸线、道路、步道、建筑、船只航线会同时移动;而在场景 v1 里,想挪一下河,得分别改水面、岸墙、道路和建筑。
提示词把「河道是基准」说清楚之后,代码自然长成了「一个中枢 + 多个消费者」。这是我觉得这次迭代里最值得记下来的一点:好的提示词不只描述结果,还会暗示结构。
五、两版放在一起看
| 场景 v1 | 场景 v2 | |
|---|---|---|
| 河道 | 沿 Z 轴的直线河道 | CatmullRomCurve3 中心线生成的大尺度 U 形弯道 |
| 河岸 | 两条直线岸墙 | 沿曲线偏移生成、宽度随河道变化的连续岸线 |
| 水面 | 矩形平面 | 沿河道的连续 ribbon 网格,岸边泡沫随岸线变化 |
| 陆地 | 两块大平面 | 带河道孔洞的连续地块,道路、步道、建筑共享河岸坐标 |
| 城市 | 以直线路网布置 | 建筑、道路、车辆、树木、路灯、码头都沿河岸曲线延伸 |
| 船 | 沿 Z 轴往返 | 沿河道中心线的不同航线连续航行,自动跟随弯道转弯 |
| 镜头 | 平直滨江视角 | 高空斜俯视,完整展示 U 形江湾与半岛 |
| 新增 | — | 货轮、两座跨江桥、码头上盖、远景城市、城市底图纹理 |

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

图 2:场景 v2 的夜景。同一条江被重新定义成一条曲线,陆家嘴三面环水,建筑、道路、船只都沿着这条曲线展开;昼夜系统在 1.9 秒内统一插值天空、太阳、雾、曝光、泛光和所有窗灯,水面参数也参与过渡。
场景 v2 做完之后,同一套场景还被用来导出了一段 28 秒的影片:四个固定镜头从 U 形江湾俯冲进入外滩立面,再环绕陆家嘴四塔,在镜头内完成黄昏到夜景的过渡。下面这张联系表是其中的六帧:

图 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 | cd material/上海外滩v2.0 |
交互方式:
| 操作 | 效果 |
|---|---|
| 左键拖拽 | 旋转视角 |
| 滚轮 / 双指 | 缩放 |
| 右键拖拽 / 双指拖动 | 平移 |
| 右上角地标列表 | 飞行到东方明珠、上海中心、环球金融中心、金茂大厦、外滩建筑群 |
| 底部时间切换 | 白天 / 黄昏 / 夜景(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 中运行。
感谢阅读。