把《枫桥夜泊》拆成四条时间轴:一首诗如何变成三维场景

封面:1.6 秒处的开场。月亮已经压得很低,石拱桥和枫林只是江岸上的剪影,月光在江面拉出一条断续的银蓝色碎影。
模型:DeepSeek-flash4.1;harness:Claude Code;能力来源:一组对齐 three.js r186 的 threejs-skills。
这篇文章复盘的是:一份把《枫桥夜泊》拆成空间、时间与机制的提示词,如何落成一个可漫游的三维场景,并导出成 40 秒、60 fps 的短片。下文把输入的那份提示词称为「世界说明书」,把产出称为「场景 v1」。
引言:二十八个字,四十秒
《枫桥夜泊》只有二十八个字。它写了月、乌、霜、江、枫、渔火、姑苏城、寒山寺、钟声、客船。如果只是把这些名词做成模型,一件件摆进画面,得到的是一张会动的明信片,不是这首诗。
诗里真正难画的是动词和时间:月「落」,乌「啼」,霜「满天」,渔火「对」愁眠,钟声「到」客船。落是一个过程,到是一段延迟。把诗变成场景,关键不是把名词堆齐,而是为每个动作找到一个可以被渲染的机制。
这份世界说明书一共十一节,从色彩、建模、动态、镜头、字幕,一直写到导出规格。它不像一份「元素清单」,更像一份世界的说明书:先规定这个夜晚的光、色、尺度和方位,再规定什么东西在什么时候动。
先说结论:这首诗最适合被拆成四条时间轴——月落的轴、风吹叶落的轴、寺院显形的轴、钟声抵达的轴。四条轴对齐在同一个 40 秒里,诗就成了镜头。
一、诗里能动的,是动词
把四句诗拆开,会看到两种东西:布景和机制。
| 诗句 | 布景(名词) | 机制(动词 / 时间) | 主要实现 |
|---|---|---|---|
| 月落乌啼霜满天 | 月、乌、霜、天 | 落、啼、满天 | sky.js、birds.js、mist.js |
| 江枫渔火对愁眠 | 枫、渔火、客船 | 并置、静默、摇曳 | trees.js、fishing.js、boat.js |
| 姑苏城外寒山寺 | 城、寺、山 | 城外(距离)、显现 | terrain.js、temple.js、雾 |
| 夜半钟声到客船 | 钟、船 | 到(传播延迟) | temple.js、water.js、audio.js |
名词决定场景的体积,动词决定动画的预算。提示词里最值钱的句子,往往不是「要有枫树」,而是「部分落叶随风飘落,偶尔落在江面」——它把一棵树从一个静态模型,变成了一个持续发生的系统。
这份提示词还有一个很清楚的取舍:它反复写「不要」。不要做成暴雪,不要科幻雷达波,不要霓虹圆环,不要平面插画代替三维场景,不要让船悬空或穿模。这些禁止项不是修辞,它们是在提前排除模型在信息不足时最容易选择的默认解。
二、先定世界基准,再谈物件
场景 v1 的所有坐标都收在一份 src/config.js 里。这不是为了代码整洁,而是为了让「江」成为整个世界的基准。
坐标系沿用仓库约定:X 轴向东,Z 轴向南,Y 轴向上,1 个世界单位约等于 1 米。河道沿 X 轴展开,z = 0 是江心;南岸是近景的枫桥、枫林、码头与客船,北岸抬升为寒山寺所在的山坡。
1 | export const WORLD = { |
水面高度、陆地高度、河宽、寺院台基,是四个共享的水平面。桥、船、树、寺、渔火都从这几个常量上长出来,而不是各自记一套魔法数字。客船到寺院在 Z 方向上相差 141.4 个单位;这个距离不是随手写的,它同时决定了第 16–26 秒镜头隔着多远的江面去「看」寺院,以及雾要浓到什么程度,才能让寺院「若隐若现」。
月亮的方向也是常量:(-0.55, 0.08, -0.83),仰角大约 4.5°。它压在西北天际线附近,所以才有「落」的余地。提示词里那句「月亮位于远山与天际线附近,呈现月落状态」,最后落成一个随时间缓慢下沉的方向函数:
1 | export function moonDirectionAt(t) { |
40 秒里,仰角从约 4.5° 降到约 2.7°。变化很小,小到单看一帧几乎察觉不到;但把它交给时间轴,月亮就真的在落。诗里的「落」不是一个状态,是一个斜率。
三、四句诗,四种机制
1. 月落乌啼霜满天
这一句有三个动作,分别落在三个模块里。
天空是一整块 shader。月盘、月海、边缘亮环、月晕、贴近地平线的薄云和稀疏星光都在同一个片元着色器里计算。星光用 step(0.9981, star) 筛出来——只保留约千分之二的网格,正好是「少量星光,不要过度密集」的分量。
乌鸦是低多边形剪影:身体、头、喙、尾,两片可以上下扇动的翅膀。三只乌鸦各自沿一条 CatmullRomCurve3 飞行,用 getPointAt 取位置、用切线定朝向,转弯时按曲率侧倾,翅膀按正弦节奏扇动。它们只在各自的时间窗里出现,所以画面里始终只有一两只,不会变成一群。
「霜满天」最容易被做错。提示词专门写了「不应把画面做成暴雪或大面积飘雪」。最终的方案是 520 个极细的霜粒,加上贴着水面缓慢流动的雾片;它们不是天气系统,只是一层几乎看不清的冷凝感。观众不会注意到霜粒,但会觉得空气是冷的。
这就是「意境」在工程里的样子:不是把比喻做大,而是把它做小,小到刚好能被感觉到。
2. 江枫渔火对愁眠
枫树是递归生成的。主干分出枝,枝再分叉,直到末梢;叶簇和带枫叶剪影的叶片卡片全部用 InstancedMesh。真正让树「活」起来的是一段注入到材质里的顶点风场:每个顶点按世界坐标和时间获得一个很小的位移,整片树冠因此以不同的相位轻轻摇动。
但只有整片摇动还不够。提示词还要「少量枫叶从树枝上脱落,飘落江面」。于是另有 120 片独立模拟的落叶:每片有自己的速度、旋转和相位,受重力和侧风影响;落到 waterLevel + 0.015 时停止下落,转为漂浮,漂 8–12 秒再重新生成。
这里有一个很实用的分层:大面积的叶子交给 GPU 的顶点着色器,少量需要「叙事」的叶子交给 CPU 逐片模拟。 如果 120 片叶子也走 shader,它们会整齐得像一场雨;如果整棵树都走 CPU,2K / 60 fps 的预算立刻不够。
渔火是另一层。江面上有五艘渔船,分布在远近不同的位置,每盏灯有自己的闪烁相位。它们通过 water.setLantern(index, position, color, power) 注册到水面 shader,最多 10 盏。船上的灯是暖橙色 #FFAD58,月光是银蓝色 #B7C9DE;这一冷一暖的对比,就是「对愁眠」里那个「对」字的视觉支点。
客船停在 (14, 23.4)。船体由逐站截面放样,木舱、竹篷、桅杆、收帆、缆绳和船头灯笼各自成组;船身随水流横摇、升沉、轻微偏航,舱内暖光与窗棂后的诗人剪影保持安静。提示词说人物「不需要复杂的人物动画」,这个克制是对的——诗人一动,画面就从「夜泊」变成了「夜航」。

图 1:9 秒处的江岸。枫树的暗红树冠压在画面上方,渔船灯火在江面拉出暖色倒影;冷的月光和暖的渔火在同一个水面 shader 里共存。
3. 姑苏城外寒山寺
寒山寺在 z = -118,客船在 z = 23.4,中间隔着 141 个单位的江面。提示词要求「建筑轮廓能够被观众辨认,但不应过于突出」「寺院与客船之间存在明确的空间距离」。场景没有靠把寺院做小来实现「远」,而是叠了三层:
- 距离:寺院真的在江对岸的山坡上,台基高度
9.2,从客船看过去需要仰视。 - 雾:
FogExp2,密度0.0029,越远越接近雾的颜色#27313F。 - 景深:
BokehPass的聚焦距离随相机与注视点更新,远处的屋檐自然变软。
寺院的建筑本身共用一套屋面生成器。山门、主殿、偏殿、钟楼、五层塔的屋顶都由同一个函数生成,用 t 参数控制屋脊高度、坡面曲率和四角起翘;改变尺寸与起翘量,就能得到不同等级的建筑。这样做的价值不在省代码,而在风格一致:所有屋檐的弧线来自同一个曲面族,远看才不会像拼贴。

图 2:24 秒处,寒山寺从雾里显形。寺院被距离、雾和景深三层推开,轮廓可辨,但不抢客船。
4. 夜半钟声到客船
这是全诗唯一一句明确写了「传播」的诗。前三句都是视觉,第四句突然变成听觉;而且「到」字说明钟声不是同时在所有地方响,它需要时间。
场景把这件事写成了绝对时间:
STRIKE_TIME = 28.6秒,撞木在 25.6 秒开始向后拉开,28.2 秒开始荡向古钟,28.6 秒撞上。- 钟体在撞击后按指数衰减摆动:
sin(strike * 4.1) * 0.105 * exp(-strike * 0.85)。 - 钟体本身还有极细的震动:
sin(strike * 38) * 0.012 * pulse,同时暖光按exp(-strike * 1.7)脉冲一次。 - 31.1 秒和 32.4 秒,江面在客船附近出现两组同心涟漪——这是钟声「到」的时刻,距离撞钟晚了 2.5 秒和 3.8 秒。
1 | const strike = time - STRIKE_TIME; |
声波的视觉化是最容易过火的地方。提示词写得很明确:「使用透明环形波纹、空气扰动等视觉方式」「不要使用科幻雷达波或夸张的霓虹圆环」。最终的实现是四道 220×220 的平面,加法混合,半径在 7 秒内从 0.06 扩到 0.76;透明度只有 0.045 - i * 0.006,波前还被噪声打散成不连续的弧。换句话说,它几乎看不见——只在寺院前的雾里留下一圈很淡的扰动。
江面的涟漪则通过 uniform 写进水面 shader:最多 6 组 (x, z, 起始时间, 强度),每组的径向斜率叠加到基础波场上。钟声抵达时,涟漪从客船附近向外扩散,和月光、渔火共用同一套法线。

图 3:34 秒处的钟楼。古钟悬在二层,撞木已经荡开;钟体的青铜色和寺院的暖光只在局部出现。

图 4:30.4 秒,钟声正在越过江面。波前被噪声打散,透明度极低,只够让雾和江面轻轻动一下。
声音本身由 Web Audio 实时合成:钟声是几组带轻微拍频的正弦分音,乌鸦是滤波后的噪声脉冲,水声和风声是噪声加低频振荡;混响发送量 0.36,让钟声在江面上留一条尾巴。成片音轨则用 Python 离线渲染,和画面在同一张时间表上对齐。
四、水面:四句诗叠在同一个 shader 里
江面是整篇文章里技术密度最高的部分。它用 Reflector 做真实的平面镜像——天空、寺院、枫树、灯火都会被反射——再在镜像之上叠一层自定义 GLSL。
这一层 shader 同时负责五件事:
- 波纹:四组不同频率、不同速度的行波叠加成斜率,再用两层低频噪声打破规则感。
- 反射扰动:用波斜率偏移反射纹理的 UV,让倒影随水波扭动;再取一次偏移更大的采样做混合,避免倒影太干净。
- 月光碎影:沿「相机 → 月亮」的方位拉出一条细长的银色路径,路径内部再用高光项和噪声切成断续的碎片。
- 渔火倒影:把每盏灯沿水面镜像,取「相机 → 镜像点」的线段,在江面上形成一条被噪声打断的暖色光带。
- 钟声涟漪与贴岸泡沫:涟漪作为径向斜率叠加;靠近岸线
|z| > 31时压暗水面,形成一条暗色水线。
月光那一段值得单独看:
1 | vec2 toPoint = normalize(p - cameraPosition.xz + vec2(0.0001)); |
它没有一张「月光贴图」。月光之所以在江面上,是因为相机、月亮方位和水面法线在每一帧重新算了一次。镜头一动,碎影跟着动;月亮下沉,路径跟着偏。渔火倒影同理。倒影不是画上去的,是算出来的。
这也是提示词里那句「通过 Shader 实现水面反射和扰动」真正的价值:它要求的不是某个 API,而是一种建模方式——把水面当成一个光学系统,而不是一张会动的背景。

图 5:16 秒处的客船。船体、木舱、竹篷和缆绳都是程序化生成;舱内暖光只够照亮窗棂和诗人的轮廓。
五、40 秒是一条函数
影片时间线在 src/film.js。它只做一件事:把任意时刻 t 映射成相机位置、注视点、字幕透明度和世界状态。
相机不是用关键帧动画软件做的,而是两组关键点加 Catmull-Rom 插值:
1 | const KEYS = [ |
每个关键点同时给出位置和注视点,两个都做 Catmull-Rom 插值。这样相机既经过每个机位,又保持速度连续,不会在关键点之间突然拐弯。35 秒之后换用另一组 FINALE_KEYS:镜头回到客船,再拉远到全景。
35 秒处有一次刻意的黑场。钟楼在江对岸,客船在南岸;如果让相机直接横跨 141 个单位的江面,观众会看到一段没有叙事的长距离平移。黑场遮住这次切镜,比硬切更自然,也比强行飞过去更诚实。
| 时间 | 镜头 | 字幕 | 主要机制 |
|---|---|---|---|
| 0–7 s | 高空俯视,月落江天 | 月落乌啼霜满天 | 月亮下沉、乌鸦飞行、霜粒与雾 |
| 7–16 s | 下降掠过枫林与渔火,靠近客船 | 江枫渔火对愁眠 | 风场、落叶、渔火倒影、船体横摇 |
| 16–26 s | 从客船望向对岸,寺院显形 | 姑苏城外寒山寺 | 距离、雾、景深、屋面生成器 |
| 26–35 s | 推进钟楼,撞钟,声波越江 | 夜半钟声到客船 | 撞木、钟摆、声波环、江面涟漪 |
| 35–40 s | 黑场切回客船,拉远,全诗淡出 | 全诗 | 第二组相机曲线、字幕淡出 |
字幕不单独做动画状态机,而是几个 fade(t, inStart, inEnd, outStart, outEnd) 窗口。applyOverlay(t) 每帧根据绝对时间设置透明度和位移;拖动时间轴时,字幕会直接落到正确的位置,不会「补动画」。
这套结构还有一个直接好处:可重复导出。 在 ?film=1&capture=1 下,页面关闭实时循环,由 window.__capture.renderAt(t) 以固定 1/60 秒步长推进;deterministic.js 用固定种子覆盖 Math.random,树叶、乌鸦、雾和渔火的随机相位每次导出都一致。2400 帧捕获了 349 秒,平均约 6.9 fps;中断后可以加 --resume 从已有帧继续。
如果所有状态都从 t 推导,那么播放、暂停、拖动时间轴、重导出就是同一个函数的不同调用方式。反过来,只要有一处动画依赖「上一帧的状态」,时间轴一拖就会漂移,导出也无法复现。

图 6:38 秒处的收尾。镜头回到客船,远处寒山寺隐入夜雾,诗名、作者与四句诗依次浮出。
六、三个看得见的坑
第一,阴影贴图不能在首帧之前关掉。
项目一度把 renderer.shadowMap.autoUpdate 设为 false,想省掉每帧更新阴影的开销。结果是首帧没有生成阴影贴图,所有 MeshStandardMaterial 在采样 sampler2DShadow 时失败,整批网格直接不绘制。画面不是「阴影不对」,而是「东西不见了」。现在保持 autoUpdate = true。这条教训很具体:性能优化必须在首帧就验证,不能只看后面的帧率数字。
第二,可重复导出不是默认能力。
树叶、乌鸦、雾和渔火都用了随机数。如果不固定种子,每次导出的帧序列都不一样:第 1200 帧的某片叶子可能在这里,也可能在那里。deterministic.js 在捕获模式下覆盖随机源,导出脚本从 0 秒重放,才让「同一份代码导出同一段视频」成立。
第三,2K / 60 fps 的预算决定了雾的做法。
提示词要求「避免过度使用高成本实时体积渲染」。场景里的雾因此是贴水的横向雾片、寺院前的雾团和极细的霜粒,而不是体积雾;阴影是单主方向光的 2048² PCF;叶簇、叶片、栏杆柱、芦苇、山树全部实例化;像素比上限 1.6。这些选择不显眼,但它们决定了 2400 帧能不能在 349 秒内跑完。
七、导出:2400 帧与两版配乐
成片规格是 ffprobe 实测出来的,不是估计:
| 项目 | 实测值 |
|---|---|
| 分辨率 / 帧率 | 2560 × 1440 / 60 fps |
| 时长 / 总帧数 | 40.000 s / 2400 |
| 视频编码 | H.264 High Profile Level 5.1,BT.709 |
| 视频码率 / 文件大小 | 39.2 Mbps / 约 188 MiB |
| 音频 | AAC LC 192 kbps,48 kHz 立体声 |
| 响度 | -18.0 LUFS,True Peak -8.9 dBFS |
| 捕获耗时 | 349 s(约 6.9 fps) |
配乐有两个版本。主版本由 scripts/compose-score.py 用 numpy / scipy 程序化合成:水声、风声、鸦鸣、两次寺钟都按时间线写入,不依赖任何外部采样。另一版用 HeyGen 曲库里的古琴 / 箫曲替换音乐层,同时保留画内的水风、鸦鸣和寺钟,画面流直接复制,不做二次编码。
这里的分工也很清楚:画面负责「看见」,声音负责「到达」。 16.5 秒的远钟和 28.6 秒的撞钟不是背景音乐,它们是第四句诗的主语。
八、这份提示词为什么有效
回看这份世界说明书,真正起作用的不是篇幅,而是六种写法。
1. 先写世界基准,再写物件。
提示词第一节不是「要有哪些建筑」,而是天空、山、江、雾、月光的关系。场景因此先有了一条江和两岸的高程,再往上长桥、树、船、寺。
2. 用 hex 和数值代替形容词。
「深蓝黑 #101A2C」「月光银蓝 #B7C9DE」「渔火暖橙 #FFAD58」——这些值直接变成 COLORS 常量,也直接约束了后处理、灯光和水面 shader。形容词只能表达偏好,数值可以验收。
3. 每句诗绑定一个镜头、一个字幕窗口、一个机制。
0–7 秒对应月落与乌啼,7–16 秒对应枫与渔火,16–26 秒对应寺院显形,26–35 秒对应钟声抵达。时间轴不是后期配的,它在提示词里就已经和诗句对齐。
4. 写禁止项。
「不要做成暴雪」「不要科幻雷达波」「不要平面插画代替三维场景」「不允许悬空或穿模」。模型在信息不足时会选最省力的默认值;禁止项就是提前堵住那些默认值。
5. 给可验收的标准。
40 秒、60 fps、2560×1440 或 3840×2160、四句诗对应四个镜头、声波与钟声同步。这些标准可以在浏览器、ffprobe 和截图里逐条检查,而不是「感觉对不对」。
6. 让所有状态由绝对时间推导。
这条提示词没有直接写出「绝对时间」四个字,但它要求时间轴拖动、自动演示、重播和逐帧导出。满足这些要求的唯一稳妥方式,就是让相机、字幕、钟摆、涟漪都成为 t 的函数。
还有一条元规则:让 skill 管 API,让提示词管世界。 提示词不去复述 Three.js 的用法,而是把篇幅留给「这个夜晚应该长什么样、什么时候发生什么」。
九、边界:场景能画到哪里
场景 v1 是风格化还原,不是唐代建筑与船型的考据。石拱桥的拱圈、客船的船体、寺院的屋面,都是从真实形制里抽出来的几何特征,不是实测模型。木材、石材、瓦片、树皮、枫叶的纹理全部由 Canvas 程序化生成;它们可控、可复现,但细节有上限。
比建模更远的边界是「意境」。构建可以通过,脚本可以全绿,ffprobe 可以逐项对上,截图可以非空;但「清冷、孤寂、悠远」最终仍然要人眼看。本文用到的七张配图只做了尺寸与非空校验,没有做人眼审美判断——那一步得留给读者。
还有一层:诗的意义不在几何里。场景能提供的是一个可以走进去的舞台,它不能替代诗,也不应该替代诗。
十、复现
场景是一个独立的 Vite + Three.js 项目,位于 material/枫桥夜泊/。环境要求 Node.js ≥ 20.19,以及支持 WebGL 2 的桌面浏览器。
1 | cd material/枫桥夜泊 |
交互方式:
| 操作 | 效果 |
|---|---|
| 拖拽 / 滚轮 / 右键拖拽 | 旋转、缩放、平移;首次操作会暂停影片并进入自由探索 |
| 空格 | 播放 / 暂停 |
1 – 6 |
全景、枫桥、江枫、客船、寒山寺、江面 |
R |
重置视角 |
| 底部时间轴 | 拖到任意镜头;松开后保持暂停 |
| 雾气 / 字幕 / 声音 | 独立开关;声音默认关闭,需点击开启 |
| “观看影片” | 进入 ?film=1 的纯净 40 秒影片页 |
导出流程:
1 | npm run dev -- --port 5188 --strictPort |
源码与完整交付记录在 open-wuxu/unreal-factory。material/枫桥夜泊/deliverables/REPORT.md 记录了成片规格、资源许可、验收清单与两版配乐的混音参数。
结语:先找到那句诗的「机制」
「绘制」这个词有点误导。我们画的不是一张图,也不是四张配图,而是一组会随时间发生的关系:月亮下沉,树冠摇动,落叶落水,寺院从雾里显形,钟声越过江面,最后抵达客船。
二十八个字里,真正需要工程化的不是名词,而是时间。月落是 40 秒的下沉,钟声是 2.5 秒的抵达,枫叶是 120 个实例的循环。诗不在模型里,在这些时间里。
下次让模型画一首诗,先别急着列名词。先问自己:哪一句里有时间?哪个动作需要一个系统?把那个系统写进第一节。
引用与致谢
- 提示词原文:
prompt/枫桥夜泊.md,与场景源码同在open-wuxu/unreal-factory。 - threejs-skills:
cloudai-x/threejs-skills,13 个按主题拆分的 Three.js skill,对齐 three.js r186。 - Three.js:MIT 许可。
- 场景源码、截图与成片:
open-wuxu/unreal-factory的material/枫桥夜泊/。文中七张图片取自该项目的deliverables/previews/。 - 模型与 harness:DeepSeek-flash4.1 在 Claude Code 中运行。
感谢阅读。