North_Cedar's notes

9,853 字约 25 分钟AI建站

从 HTML 到莱茵生命:我和上一个 AI 建站踩过的坑

一个博客、三版重构、几十个真坑:模型绕着自己转、无限符号左右放反、逐字抄来的 CSS 静默失效、谁偷偷换掉了 fixed 的包含块——一个个怎么查出来的。

这篇文章写过一版,9 月 30 日。那一版只写到「改版式」——把视觉原型搬进正式站、把版式调对。发出去之后日子还在往下走:除了首页,每一页都加了一台可以上手玩的装置;相册推倒重排了一次;底纹前后换过三轮母题。两个星期后再看那篇文章,它对了一半——「真正花时间的不是写出来,是写完之后」这句是对的,但它把边界画小了。

写完之后真正花时间的,是「怎么知道它到底对不对」。

所以这一版把它补全。坑还是那些坑,只是换了一批面孔;底下的道理还是同一批,只是当时我只摸到一半。

一、坑一共五类

类型 典型症状 正确的解法
环境类 本地能跑线上不行、改了没反应 先排除缓存和路径,别动代码
静默失效类 代码没错、肉眼看得见不对,浏览器一条错都不报 去看渲染出来的 DOM / 计算样式,别读源码
坐标系类 “绕着自己转”结果在晃、“居中”结果偏了 量,不要靠看的
度量类 用错指标,越调越歪 换一个能被独立复核的指标
判据类 你以为的“验证”根本没验到东西 先让判据自证它量到了什么

第三、四、五类最要命,因为它们不报错。第一类最烦,因为它浪费最多时间。

第五类(判据类)是写这版才补上的——它不是我遇到的第五种 bug,是我用来抓 bug 的那把尺子本身也会错。这件事在我第一次写这篇时完全没意识到,见第六节。

二、环境类的坑:全是“看起来像代码问题”

2.1 file:// 下加载 3D 模型必失败

一开始为了省事,直接双击 HTML 用浏览器打开。结果 3D 模型死活不出来,控制台报一个 CORS 错。

原因是 GLTFLoader 内部是用 fetch 去取 .glb 文件的。file:// 协议下 fetch 被浏览器直接拦掉——不是配置问题,是设计如此。

这条记下来:只要页面上有 3D、有 fetch、有 module 脚本,就必须起本地 HTTP 服务。

cd prototype && python -m http.server 8080

(顺带一提:<script type="module"> 在 file:// 下同样会被 CORS 拦。所以“能不能双击打开”这件事,其实是判断这个页面能不能离线跑的快速试纸。)

2.2 改了同名文件,浏览器还是给旧的

同一个文件名反复覆盖测试页,浏览器一直显示第一版。同名 HTML 会走缓存。

修法有两层,两层都要做:

  • 服务端回 Cache-Control: no-store, no-cache
  • 调试时不放心就换文件名(v1.html、v2.html……),或者开无痕窗口

只改一层有时候还是会被吃掉,尤其是无头浏览器做自动化验收的时候。

2.3 路径被 shell 悄悄改写

在 Git Bash 里跑命令,URL 里的 /probe/x.html 会被 MSYS 当成 Windows 路径自动转义,结果请求发到 http://127.0.0.1:9703C:/Users/.../PortableGit/...,页面打开是空白。

修法是加个环境变量前缀:

MSYS_NO_PATHCONV=1 curl http://127.0.0.1:8080/probe/x.html

这个坑的隐蔽之处在于它不报错——请求确实发出去了,只是发到了错的地方。

(这条后来又踩了一次:给验收探针传页面列表时写 PAGES="/about/",同一个机制把它转成 D:/…/PortableGit/about/,探针于是去加载一个不存在的本地文件,表现为“页面 DOM 是空的”,看着像线上坏了。)

2.4 404 是噪音,不是 bug

开发时控制台一直有一条 favicon 的 404。一开始当成 bug 查了半天,其实只是没放 favicon。验收脚本要按模式过滤掉已知无害的错误,否则每次都要重新判断一遍“这条是不是新问题”。

2.5 直接双击打包出来的 dist/index.html,整站 CSS 都会没

这是 2.1 的进阶版,专门针对打包后的站点。

现象是站主给我反馈的:“DNA 显示不出来,左上角 logo 也没显示。“听起来像个渲染 bug,我当时也是这么查的。

真相要糟得多——不是这两个元素坏了,是整站 CSS 和 JS 都没加载。

原因是站里的资源路径全是 /_astro/xxx.css 这种绝对路径:

  • http:// 下,它解析到站点根目录,正常
  • file:// 下,根目录变成了磁盘根目录,请求实际发到了 file:///D:/_astro/xxx.css —— 必然找不到

用同一个对照探针把两种方式各跑一遍,差别一目了然:

file://   dnaCanvasPresent: false    logoMaskBg: rgba(0, 0, 0, 0)    6× ERR_FILE_NOT_FOUND
http://   canvas 836×1295 .is-ready  logoMaskBg: 正常

所以现在这个站的 package.json 里专门挂了一条 preview:note:想看效果跑 npm run preview,别双击 HTML。

记下来:打包产物里的绝对路径,决定了它只能配着 HTTP 服务看。 判断“到底是代码坏了还是打开方式不对”,最快的办法就是同一份产物用两种方式各加载一次。

2.6 推了,线上却没变

这条是后来才补进这一类的,因为它的症状和“代码写错了”一模一样,但根子在别处。三个真实原因,按命中率排:

  • 构建失败了。 有一次我把 239 个临时文件一起提交进去,Cloudflare 那次构建直接 Failure,而线上继续挂着上一个成功的部署——点开站,一切都对,只是不是最新的那次。所以「push 了没变」第一件事是去看那次构建的状态,不是去看代码。
  • CDN 缓存。 有一次提交的部署已经是 Active、源正确无误,curl 回来却是旧页面——加一个随机查询串(?cb=$(date +%s))再查,或者直接查部署域名(它不过 CDN),立刻就对上了。
  • 看错了判据。 有一次我拿一句 CSS 去线上 HTML 里找,轮询了十几遍全“没命中”,以为没部署——其实样式是外部文件(/_astro/*.css),HTML 里本来就没有那句话。判据找错了地方,看起来和“没部署”一模一样。

记下来:门闩要绑本次改动本身(改色就查那个色值),而且要绕开缓存。绑错了内容,被缓存骗了,照样是假通过。

三、静默失效类的坑:样式不生效,却一个错都不报

这一类是我以前没遇到过的,也是改版式那一轮最贵的一类。它的特点是:代码一行没写错,肉眼看得见那里出了岔子,但浏览器一个错误都不报。

3.1 现象:从一个跑得好好的原型里逐字抄来的东西,到站上就废了

当时在把一个视觉原型移植进正式的博客站。原型是单文件 HTML,页面上有三个关键元素:

  • 左上角一个会轻微摆动的 logo
  • 首屏右侧一根倾斜的 DNA 双螺旋(three.js 渲染)
  • 背景一层跟着鼠标晃的流场线“蛛网”(几百条 SVG path)

三样在原型里都是好的。移植时我以为自己是把 CSS 逐字搬过来的(后来发现并没有,见 3.5),素材也换成了新做的一批。结果上线一看:

  • DNA 立起来了,像根竖着的柱子,不像之前那样斜着
  • 蛛网糊成一片
  • logo 看不见

第一反应当然是“three.js 的倾角被我改坏了”——毕竟三个里面最复杂的就是它。

3.2 为什么一开始就猜错了:这三样东西的 DOM 不是写在模板里的

  • DNA 的 <canvas> 是 three.js 在运行时 createElement 出来的
  • 蛛网的 <path> 是脚本逐条 createElementNS 拼出来的

而这个站用的是 Astro,组件里的 <style> 默认是作用域化的:编译的时候,Astro 会把选择器拆开的每一段都挂上一个 [data-astro-cid-xxxx] 属性限定。而这个属性,只存在于编译期就已经在模板里的那些节点上。

于是这条样式:

.dna-stack canvas { width: 100%; }

在产物里其实是这样的:

.dna-stack[data-astro-cid-x] canvas[data-astro-cid-x] { width: 100%; }

右边的那个 canvas 是运行的时候才喂进来的,它身上没有那个属性,选择器就永远不成立。

这不是报错,这是静默失效——“选择器没匹配上”这件事,浏览器连一条警告都懒得给。

顺带回答一个必然会问的问题:同样的 CSS 在原型里为什么没事?因为原型的 <style> 就是普通样式表,没有人往里面加属性限定。同一个东西换个地方放,行为就可能变了。

3.3 查法:别盯着 CSS 写得对不对,去看 DOM 实际拿到了什么

靠的是一个很小的探针脚本,它把“我以为应该生效的值”和“浏览器实际算出来的值”打在同一行:

canvas_hasCid: false     computed width: 3455px              ← 期望 100%
path_hasCid:   false     stroke: "none"   fill: "rgb(0,0,0)"

两行指向同一个结论:选择器没匹配上。

这里还连带炸出第二个坑。我原来以为 SVG path 的样式失效,顶多就是“不显示”。错了——SVG 的 path 默认 fill 是黑色。样式一失效,那几百条线不是消失,而是变成几百个黑色实心多边形糊在一起,比不显示难看得多。而那个用来救场的 fill: none,恰恰就写在那条没生效的选择器里。

这个组合特别阴险:你以为是“没出来”,其实是“出来了、而且是错的”,两个方向的排查路径完全不同。

3.4 修法::global()

Astro 给的开关是 :global(),标了它的那一段不吃 cid 限定:

/* ✗ 编译后是 .dna-stack[cid] canvas[cid] —— 运行时注入的 canvas 永远匹配不上 */
.dna-stack canvas { width: 100%; }

/* ✓ canvas 这一段不限定 */
.dna-stack :global(canvas) { width: 100%; height: 100%; display: block; }

同一个 :global() 一共修了四处,其中两处是既有代码里本来就有的老 bug(一个列表页的 li 分隔线和元信息字号,一直没生效、一直没人发现):

.rh-grid   :global(path)   { fill: none; stroke: var(--on-band-sub); opacity: .62; }
.dna-stack :global(canvas) { width: 100%; height: 100%; display: block; }
/* 下面两处是列表页里本来就存在的同类 bug,顺手一起修 */
.clist           :global(li)  { border-bottom: 1px solid var(--hair); }
:global(.cmeta)               { font-size: var(--fs-xs); color: var(--muted); }

改完再跑一遍探针:

canvas   computed 354.359 × 1260                                ✓
path     stroke: rgb(222,222,222)   fill: none   ×101 条        ✓

3.5 同一批改动里的另一类错:三个数抄漏了

同一轮里 logo 也坏了,但它跟作用域没关系,纯粹是移植的时候漏了三个值:

项 原型 移植时写成了
遮罩素材 inf-mask.png(722×570) 一个更早版本里已经废弃的 webp
宽高比 722 / 570 1200 / 564(旧素材的比例)
摆动 ±4.2° / -7px / 7.5s ±1.6° / 9s(被我“顺手优化”过)

前两个还好查,第三个最难发现——因为它不是错的,只是变小了。±4.2° 的摆动一眼就能看出在晃,±1.6° 几乎看不出来,于是就停在“感觉不对但说不出哪里不对”的状态里。

最后也是靠脚本:隔一小段时间采两帧(确保落在摆动的不同相位),把 .lg-box 的 computed transform 矩阵反解,和 animation-duration 一起打出来对照:

animation-duration: 9s   ← 期望 7.5s
transform: matrix(...)   ← 反解出来只有 ±1.6°

记下来:移植 = 逐属性值对齐,不要顺手优化。 那 ±4.2° 是站主之前来回调过的定稿,我却当成了一个可以随便微调的参数。

3.6 这一类的通用规则

只要用的是带组件作用域 / CSS Modules 的框架,凡是运行时才插入的 DOM,写样式时必须显式声明全局。

反过来也成立:验收的时候如果一个动态元素的样子不对,先查它身上有没有选择器要求的那个属性,再去怀疑几何、动画和第三方库。

3.7 后来又撞见四条同族的:文本没问题,只有渲染才现形

这一类到了做装置那一段还在长,而且每一个都骗过了读代码的人:

写法 表面上 实际
transform: translateX(var(--x) 0) —— 变量值漏了单位 代码没写错 CSS 变量替换后整条 transform 作废,动画根本没跑(但量时长、量透明度全是对的)
@keyframes 里写 transform 看着是在加动画 它会整体覆盖基础声明里那句居中 ⇒ 第一帧元素就跳到右下角(要把居中拎成一个变量、关键帧里带回来)
@media (prefers-reduced-motion: reduce){ animation: none } 写在基础 animation 之前 降级分支“有了” 同权重后写胜 ⇒ 被覆盖,动画照播。而断言只查“有没有这个分支”,照样全绿
SVG 图标的注释里写了 --accent(两个连续短横线) 路径、颜色、尺寸的字符串断言全过 XML 规范禁止注释含 -- ⇒ 浏览器整个文件解析失败,图标不显示。只有真渲染一次才拦得住

四条的形状一模一样:文本层看起来无懈可击,错在“浏览器怎么解释它”。 这一类的验收只有一个办法——渲染一遍。

四、坐标系类的坑:最像玄学,其实全是确定性的

4.1 “这个东西要绕它自己的中心转”

一个 3D 模型,要求很简单:绕着自己的中心轴自转。

第一版做出来,效果是左右晃——不是转,是整根模型在画面上左右平移,像被一根看不见的绳子甩。

我猜了两次,都错了:

  • 第一次猜是层级嵌套错了,重排了一遍父节点
  • 第二次猜是倾斜角度带来的耦合,把倾斜角去掉了

都没用。然后决定停下来读原始数据——不再看渲染结果,直接解析模型文件。

模型的顶点数据可以直接读出来。对全部顶点做主成分分析(PCA),能算出模型的长轴方向:

长轴方向 PCA ≈ [-0.0001, 1, -0.0001]   夹角 0.01°

也就是说,模型的长轴几乎完美地沿着 Y 轴,没有倾斜、没有偏移。那么问题就不可能出在模型本身,只能出在变换上。

于是列了六七种变换方案,全部塞进同一个页面里同时跑,一次性出对照表。结果一眼就看出来了:

方案                       旋转一圈的漂移量
──────────────────────────────────────────
旧顺序(先平移再缩放)      292.7 px   ← 就是它
新顺序(先缩放再平移)        0.3 px

根因是缩放和位置的施加顺序。

在很多 3D 引擎的语义里,position 是在父空间里平移,它不受自己 scale 的影响。所以如果先写 position = -模型中心,再写 scale = k,那个平移量是按原始尺寸算的,不会跟着缩小。模型被放大了 3.2 倍,平移量却还是原来的数——于是模型的旋转轴被推到了离旋转中心很远的地方,它就在绕着离自己很远的一条平行轴公转。视觉上就是“左右晃”。

修法只有一行顺序的差别:

// ✗ 错:平移量没跟着缩放走
src.position.set(-center.x, -center.y, -center.z);
src.scale.setScalar(k);

// ✓ 对:先缩,再把平移量乘上去
src.scale.setScalar(k);
src.position.set(-center.x * k, -center.y * k, -center.z * k);

漂移量从 292.7px 降到 0.3px。这不是调参,是修 bug。

4.2 拿“包围盒”去量一个转起来的物体

修完上一条之后,又要用包围盒回推一下位置,结果又歪了。

原因是:包围盒(Box3.setFromObject)返回的是轴对齐包围盒(AABB)。一个细长的柱体倾斜 26° 之后,它的 AABB 宽度会从真实的 113 虚胀到 500 以上——因为 AABB 永远是“贴着世界坐标轴”的那个外框,物体一斜,框就鼓起来了。

用这个虚胀的框去算中心,等于把物体推出了画面中轴。

教训:量物体本身的尺寸,必须逐顶点投影,不能用 AABB。 AABB 只适合做“粗略可见性剔除”,拿它当几何尺寸用必翻车。

4.3 “居中”这件事:符号偏了,但我量不出来

这个是耗时最久的一个,因为我连续两次都调反了方向。

现象:一个装饰性的无限符号,站主连着说了两次“偏左,往右调”。我调了一次,他说“你往左边放干嘛”。

第一次的错误很典型。我用“全画布的透明度加权质心”去量符号位置,量出来是 786.6,画布中心是 786.0——差 0.6 像素,看起来完全居中。于是我判断“本来就居中,不用改”。

问题是:这张原图的画布里散布着极淡的噪点,透明度 0–31 那一档占了全图 90.9% 的像素。这些噪点把质心稀释到了画布正中间,掩盖了真正墨迹的位置。

换了量法之后真相立刻出来:

正确量法:先 alpha > 64 去噪,再用累积占比边界
墨迹边界  x[388, 1571] y[211, 666]   →  宽 1183  高 455
左留白 388 px      右留白 0 px(右缘出血被裁掉了)
墨迹中心 979.5     画布中心 786.0    →  右偏 +193.5 px (12.3%)

符号本体重心确实偏右 12%。而我第一次“重裁居中”生成的那张图,实测左偏 144.7px——我把符号从偏右推到了偏左,难怪站主说方向反了。

最后用几何边界中心对齐(还试过强度加权重心,结果因为左半边的缎带更亮更实,强度重心整体偏左,反而把符号推右了 +249px),生成了严格居中的版本:

新图 1251×393
  墨迹中心 X 626.0  vs 625.5  →  +0.5 px
  墨迹中心 Y 195.5  vs 196.5  →  -1.0 px
  左留白 35 px      右留白 35 px      ✓

三条教训,都写进备忘:

  1. 量之前先去噪。 全画布有极淡像素时,裸质心一定是错的。
  2. 视觉居中对齐要用几何边界中心,别用强度重心。 亮度分布不均匀会把重心带偏。
  3. 改之前先量化“现在偏多少、偏哪边”。 第一版的实际偏移是 -144.7px,我连这个数都没量出来就开始改,方向不反才怪。

4.4 同一类问题的第二个:图形相减

还有一次是“两个形状相减,减完的位置不对”。原因类似——参与相减的两个形状,一个自带偏移、一个在原点。任何“组合/运算”类操作,先确认两边的坐标系原点是否一致。

4.5 后来这一类最反复的一条:谁在给 fixed 的子孙定“包含块”

这一条在做装置那一段反复出现,前后撞了三次,每次症状都不一样,所以单独列出来。

CSS 里有一个不常被提起的规则:某些属性会“提级”成为它内部 position: fixed 后代的包含块**。**

平时 fixed 是相对视口定位的;但只要它有一个祖先带了 transform / filter / backdrop-filter / perspective / will-change / contain 里的任何一个,fixed 的参照系就从“视口”变成”那个祖先的盒子“。

三次的表现:

  • 关于页:底纹层被放进了会被页面过渡加 transform 的容器里 ⇒ 一打开页面,背景从下往上跳了一步(实测偏 91px)。
  • 归档页:右侧那把卷尺同理,过渡那 900ms 里整个偏了 596px——站主截图里两个「2026」叠在一起。
  • 手机端导航:内页顶栏那层毛玻璃用了 backdrop-filter,于是它顺带给抽屉(fixed; inset:0)当了包含块 ⇒ 抽屉不再铺满屏幕,只铺顶栏那条 62px 的细带,十个链接全被裁掉。站主给的截图里,抽屉只剩「导航菜单」四个字。

同一个“提级”还会顺手做第二件事:建立层叠上下文。所以那一刻会同时冒出两个问题——几何被裁 62px、抽屉的 z-index 被锁进顶栏内部(而正文留在外面)⇒ 只修几何,会得到一个“位置对了但被正文盖住”的抽屉。

三次撞完,沉淀成三句话:

  1. 判“包含块是谁”要把整组属性一次查全——只查 transform 会漏掉 filter(我第一次就漏了,还据此下了“没有包含块”的错误结论)。
  2. 别让装饰层待在会建立包含块的祖先里——这个站的 plate 槽就是为此存在的:它在 .shell 之外,天然免疫。
  3. 改一个会“提级”的属性,先看它的两个后果——包含块和层叠上下文是同一句话的两半,修一半等于没修。

(顺带:无头浏览器里查这个,靠的是“面板正中那一点 elementFromPoint 命中了谁”。修前跑九个页面,八个红,直接报出 div.topbar[backdrop-filter];修完全绿。)

五、度量类的坑:不报错,但一直在错

5.1 存 WebP 把透明压成了黑

一张只有符号是实心、其余 95.4% 是透明的图,存成 WebP 之后铺在白底上,出现了一大块黑色方块。

原因是保存时做了一次 .convert('RGB')。这个操作会把 alpha 通道直接丢掉,而丢掉 alpha 之后透明区默认变成黑色,不是白色。

修法是直接存 RGBA:

canvas.save(out, 'WEBP', quality=93, method=6)   # 不要 .convert('RGB')

这类坑的共同点是:它不报错、不警告,等你在浏览器里看到那个黑块才知道。所以涉及到“带透明的素材”时,保存完一定要回读一遍验证 alpha 通道还在。

5.2 “看起来在动”和“确实在动”

验收 3D 自转的时候,肉眼看完全没问题。但我还是写了个像素级的复核:连续截 14 帧,每帧都去量“非背景像素的包围盒和质心”。

14 帧亮像素包围盒:恒定 276 × 739
质心漂移:3.26 px

包围盒恒定就证明它是在原地自转,而不是在画面上漂移。肉眼看 14 帧的差异很难,但量化之后是确定的。

这条是这次最大的收获:任何一个“看起来对/不对”的视觉问题,都能找到一个可以被独立复核的数字指标。找到它,就不用猜了。

5.3 别用 aspect-ratio 反推宽度:那是个循环

接着上一节的思路再补一个同类——这次是布局里的循环依赖。

DNA 那个容器,原型里是 900 : 3200 的细长一条,我也照抄了:

.dna-stack {
  position: absolute;
  height: min(140vh, 1520px);
  aspect-ratio: 900 / 3200;   /* 指望它自己算出 354px 的宽度 */
}

在原型里它算出 354.375px,完全正确。搬到站上,同一个写法算出 3455px,把首屏撑开了近十倍。

根因是这个宽度压根不是由那条 CSS 比例决定的,它是一个循环依赖:

  • 容器是 position: absolute 且宽度 auto ⇒ 走“收缩适应”,由内容撑开
  • 容器里唯一的内容是 <canvas>,它的 CSS 宽度是 100% ⇒ 又回头取决于容器宽度

“容器宽 ← canvas 宽 ← 容器宽”。浏览器处理这种循环有自己的一套规则,而收敛点取决于父容器的可用宽度,跟我写在 CSS 里的比例关系不大。原型里父容器 1440px,恰好收敛到 354px;站上父容器 1216px,就发散到 3455px。

同一个写法,换个父容器宽度,结果差一个数量级。这不是浏览器的问题,是我用了一个依赖隐变量的写法。

修法很朴素:把宽度显式算出来,别让它去猜。

.dna-stack {
  width: calc(min(140vh, 1520px) * 900 / 3200);   /* = 354.375px,写死 */
  height: min(140vh, 1520px);
  aspect-ratio: 900 / 3200;                       /* 留着只当文档说明 */
}

通例:aspect-ratio 适合在“一边已经确定”的时候推另一边。想让它同时决定两边,实际生效的是浏览器那条谁也记不住的循环规则。

5.4 拿不准倾斜了多少:直接对渲染出来的像素做 PCA

回到这一轮最开始那个问题——“DNA 怎么竖着了,之前不是斜着的”。

我的第一猜是 three.js 那一层错了:相机宽高比被拉伸了,或者旋转轴写反了。这个猜测很贵,因为要动的是整套 3D。

动它之前,先做一件事:量一下它现在到底倾斜多少。

两个现成的工具都不可靠:

  • 欧拉角:先绕哪个轴、什么顺序,我自己都得查一遍,而且很容易把模型内部的旋转和容器外层的旋转搞混
  • 包围盒:见 4.2——物体一斜,AABB 就虚胀,量出来的不是真实尺寸

最后用的是最笨也最稳的办法:截图,然后对渲染结果做 PCA。

  1. 截图取这一片区域
  2. 挑出明显亮于背景的像素(DNA 是浅色,背景是深墨绿,很好分)
  3. 对这些像素的坐标算协方差矩阵,取主成分方向
  4. 主成分方向与竖直方向的夹角,就是它的视觉倾角

这招的好处是:它量的就是屏幕上肉眼看到的那个东西本身,中间不经过任何坐标系换算,也就无从搞错。

            亮像素数      倾角
原型        65925         25.55°
本站        65960         25.56°

差 0.01°。3D 那一层分毫不差——我完全猜错了方向。

真正的差别在容器:原型是一个 354×1260 的细长条,我给它套了一个 520×702 的偏方的框,还漏掉了 translate(0, -50%) 的垂直居中和那层径向蒙版。细长的东西被塞进偏方的框、又没居中,看起来就成了“竖着的柱子”。

这条值得单独强调:越是你敢肯定它错的那层,越应该先量一下再动手。 我本来准备去改复杂的 3D,实际错的只是一行容器尺寸。

5.5 同一把尺子,量不了两种东西

做完十台装置之后,底纹换过几轮母题。每换一张新素材,都要重新定“它该多浓”。站上有一把自己攒的尺子——差分:同一页渲染两遍,一遍把底纹关掉,逐像素相减,于是量到的就是底纹自己贡献的那点墨。

用这把尺子量“相册页那张新 ∞ 该多浓”的时候,我差点栽了。

新素材的 mean 明显低于它要替代的那张旧底纹,我第一反应是“太淡了,得调浓”。但那个判断是错的:mean 的分母不一样。

  • 新 ∞ 是紧裁过的手绘线描,墨迹只占图面 30.5%,铺到屏幕上只碰到 3.4% 的像素;
  • 旧底纹是一整张铺满型的褶纸,铺满 10.45%。

两个不同分母的 mean 直接互比,会得出完全相反的结论。换成看峰值(笔画最深的那一档)立刻就对上了:

相册新 ∞        峰值 20
日志页 ∞        峰值 20   ← 用户已接受的那一档
旧百褶 ∞        峰值 21

记下来:一个指标只有在“分母/口径相同”的前提下才能跨界比较。 拿 mean 比 mean 之前,先问一句:这两张图的“面积”是同一个东西吗?

5.6 形态量过一次,就不能“推演”下一次

有一台装置要求“线再稀疏一点”。我以为这只是把几个数字改小,改完再看效果就行。

错了。那台装置的线、碎屑、分叉、同心环,全部从同一条带种子的随机流里取数。改任何一个计数,后面所有取值的位置整体错位——得到的不是“原来那张网减掉几条线”,而是另一张网。

后果很反直觉:我把线和碎屑一共减掉了近一半(画布非透明像素 113609 → 55999),“随处能碰到”的覆盖率反而从 92.86% 升到了 94.67%——因为新那张网的环恰好铺得更匀。

记下来:带种子的程序里,“改一个常量”和“改整个形态”是同一件事。 种子给的是可复现,不是可微调。形态类的读数(覆盖率、密度、分布)每次都必须重新量,一步都不能推演。

5.7 “画了但太弱”和“根本没画”,是两回事

有一次站主说“那个效果我没看到”。

我先怀疑是没触发、没执行。插桩一看,链路全通——只是整段生命的着色像素峰值只有 229(正常划过整屏是 3~4 万)。

它不是没画,是画得太弱,弱到看不出来。按像素数量级重做之后,峰值到了 9610。

记下来:视觉验收必须有一条“像素数量级”的判据。 “有没有”和“够不够”是两个问题,症状却都是“我没看到”。

六、第五类:量它的那把尺子本身,也会错

这一节是写这版才敢加的。前面几节讲的都是“怎么把东西做对”,这一节讲的是**“怎么知道自己做对了”**——而这两件事的难度,我以前严重低估了第二件。

我有过好几次这样的经历:写了一组断言,跑,全绿,安心了;过了几天发现问题还在。不是断言失效了,是它从来就没验到我要验的东西。

6.1 一条判据,先得能证明它量到了东西

最典型的一次:我给一个底纹换素材,想量它到底有多浓。写了个采样框,量绝对灰度。跑一遍,五档取值(包括把整层关掉的对照档)全部读回 最暗 249 / 墨量 1.0——五档一模一样。

表面上看,“五档数值一致”很像个稳结论。实际上它什么都没量到:那个采样框落在了一片空白上。

判据错的方式有很多种,但最根本的一种是:它根本没在量你要量的东西,而它不会告诉你。 所以现在的规矩是——每条判据都要先自证:给它喂一个“本应显著不同”的对照,读不出来,就说明判据废了,而不是“结论稳”。

(后来给装置做的差分口径就是这么定的:底部放一条色带,按实际计算值变色,绿了才算这次读数有效;还把它量的是哪一层也印在读数里,让每行数字能自报身份。)

6.2 判据的“范围”会漏

给相册页的新底纹写契约时,我断言“产物里的 CSS 必须有那条关掉旧底纹的规则”。跑,红的。查了半天源码——源码明明是对的。

真相是:Astro 会把小组件样式内联进页面,那份样式整段躺在 dist/gallery/*/index.html 的 <style> 里,而我的断言只扫了外链的 dist/_astro/*.css。字面量一个字没错,是我查的范围漏了。

6.3 “函数里出现过某个调用”是弱判据

有一处几何,命门在于“环的两个端点都必须从放射线上反查”。我写的断言是“这段代码里出现了 crossAt(”。

问题:那个函数里有两处 crossAt(两个端点各一处)。把第一个端点改成“按角度直接算”之后,第二个仍然满足这条断言 ⇒ 一条半悬空的线照样过门闩。

判据要落在“这条数据是从哪来的”,不是“这个名字出现过”。 只要同一个调用出现两次以上,“存在性”断言就必然漏掉一半。

6.4 只测落点、不测线段

有一类几何是“线要绕开文字”。我最初只判“新落点有没有撞上文字盒”。

错了——线段两端都在盒子外面,中间照样能从盒角上切过去。 视觉上就是“线从字角上划了一下”。后来补上“取线段上 1/4、1/2、3/4 三个内点”才算数。

(同族的还有“只测顶点、不测相邻顶点连成的那一段”,在两条不同的装置上各踩了一次。)

6.5 门闩也会被“换一种写法”绕过

有一条门闩是“某个盒子不许有底色”。我实现成“取这个块里的第一条 background 声明,断言它是 none”。

而 CSS 是后者胜——在后面悄悄再补一条 background,整个门闩就被绕过去了。

修法是取最后一条。 教训是:门闩要按“浏览器实际会怎么做”来写,不是按“我期望源码长什么样”来写。同一件事还有更狠的一版:写 CSS 时手滑漏了个分号,那条声明连同后面一条一起被浏览器丢弃,而我的断言读的是文本里的那个数字——数字还在,断言全绿,声明早就死了。所以断言必须连“终止符”一起查。

6.6 门闩要能被咬住——而且要经常重新咬一遍

以上这些都指向同一个动作:证伪。

我现在的做法是给每条重要门闩准备一个“变异”——故意把源码改坏,看门闩是不是真的会红。第一次做的时候,一组十三条只有一个被咬住。那一次很打击人,但也很有用:它说明另外十二条都是摆设。

坑还有两层:

  • 变异脚本自己的期望标签也会写错。 见到“没咬住”先别改源码,先看套件到底红了没有——可能是门闩没问题、是我把期望写错了。
  • 变异脚本会被中途杀掉,把改坏的源码留在仓里。 而它多半是个未跟踪文件,git status 看不出来。所以现在每轮跑完都要回读源码确认已还原。

七、从“搬原型”到“造装置”

前面六节讲的,大多是“把东西做对”。第九轮往后,站主给了一句新要求:“除了主页,其他页太素了,每页都要不一样、都要有个能上手玩的东西。”

于是这个站从“把视觉原型搬进正式站”,变成了“自己做装置”。这是这一路真正的转折,也是那篇 9 月 30 日的文章完全没覆盖到的一段。

后来一共做了十台。归档页一条随滚动下滑的索引卷尺、关于页的亲笔签名、文章页跟着滚动抽出来的阅读尺、说说页斜飘的雨线与涟漪浮游、日志页那支不用一行 JavaScript 的走时仪、友链页越连越密的星座、留言页会自己收干净的墨滴、标签页贯穿全屏的准星、404 页鼠标划过才缓慢裂开的裂痕、相册页那块被指针搅动的显影板……以及首页那串滚到底就进相册的蝴蝶切片。

做装置的过程里,有几件事和“搬原型”完全不是一回事:

第一,审美主张要能被翻译成一条可验证的关系。 相册页那台螺旋盘,要求是“一眼看出是螺旋,而不是一条斜着排的照片带”。这不是审美题,是几何题:第一版每片只转 18°、可见窗口只扫 64.8°,投影在可视范围内单调递增,读出来就是一条斜带;改成每片 28°、窗口扫 140° 之后,远端收回来了。于是门闩钉的是一条关系——“窗口扫角 ≥ 110°“且”卷回率 ≤ 0.75”——两个旋钮以后随便怎么调,这条性质都守得住。

第二,形状必须是时间的确定函数。 那台显影板有一版用了 Math.random,结果同一时刻两次加载形状就不同,“同一时刻逐点比对”(唯一可信的形状类验收手段)直接失效。从那以后硬规矩是:形状一律是 f(performance.now());如果站主要“每次打开随机出现一张照片”,随机只准出现在选片那一行,别处一律不许。

第三,先造尺子,再调旋钮。 有一轮站主说“友链页那个星链出现得太慢,快一点”。我第一次靠猜(把速度乘 2.3),他还是说慢。第二次不猜了:写一把尺子——在页面最前面同时劫持帧循环和时钟,把动画变成可按 60fps 精确步进、每帧读一次画布。量出来才知道,线其实 67ms 就已经看得见,慢的是“浓度爬满”那 0.37 秒,而且只该动一个时间常数。改完到顶时间 100ms,快了 4.3 倍。

第四,“像水一样”这种观感,要去读参考站的主循环,不是对着看得见的形状调参。 显影板做前五轮,我一直在调笔触(形状、大小、软边),一次都没调对。第六轮推倒重来,把参考站的实现扒开逐行看才发现:“像水”根本不在笔触里,在场是怎么推进的——密度场每帧被一个卷曲噪声流场输运,少了那一行,擦痕盖上去就冻住。

八、几条方法论

按实际用的频率排:

1. 别猜,先读原始数据。 3D 模型可以直接解析顶点做 PCA;图像的墨迹可以逐像素量边界;色彩可以读计算后的样式。渲染结果只是“数据的表现”,数据本身永远可以查。

2. 做“多变体同页对照”。 一个页面里同时跑 5–6 种方案,一次渲染出对照表。比“改一版看一眼”快一个数量级,而且不会因为来回改而记混。

3. 度量要选对指标。 这是最容易偷懒的一步。AABB 不是几何尺寸,裸质心不是视觉位置,文件体积不是语言占比,mean 不是浓度。换个指标重算一遍,如果结论变了,说明原来那个指标是错的。

4. 环境问题优先排查,别改代码。 “改了没反应”99% 是缓存或路径,不是逻辑。先开无痕、先换文件名、先看请求到底发到了哪个地址。

5. 把“确认”变成脚本。 一次验收写完,跑一遍几十秒,比手动点一遍可靠得多,下次还能用。(也是因为这条,才有了第五节那台“差分尺”。)

6. 移植不等于复制粘贴——“换个地方放”是会改变行为的。 同一个选择器,在单文件 HTML 里对整页生效,进了带作用域的组件就只对静态 DOM 生效;同一个 aspect-ratio,换个父容器宽度就收敛到另一个值;同一层底纹,挂进会被 transform 的容器里就改了包含块。搬完之后按原属性逐条复核一遍,比搬本身更费时间,但这一步省不掉。

7. 先让判据自证。(第六节) 凡是要“量一个东西”,先喂它一个“本应显著不同”的对照。读不出来,是判据坏了,不是结论稳。

8. 门闩要能被咬住。 写完断言,故意把源码改坏一次,看它红不红。不红,说明它是摆设。

9. 只有真渲染拦得住。 作用域、CSS 变量、@keyframes 覆盖、XML 注释里的 --、backdrop-filter 的包含块……这一整族的共同点是:字符串断言全绿,页面是坏的。 该截图就去截图。

10. 形态量过一次,不能推演下一次。 带种子的程序里,改一个常量等于换一张图。形态类读数每次都重新量。

九、关于“和 AI 一起建站”这件事

9 月 30 日那版写到:我的角色从“下指令的人”变成了“验收的人”。

做完十台装置之后,我想把这句话再往下推一层。“验收”这个说法其实还是乐观了——它假定我知道“对”长什么样。 但很多次我都不知道:

  • 我不知道“一眼看出是螺旋”该怎么验收,直到把它翻译成“窗口扫角 ≥ 110°”;
  • 我不知道“星链出现得太慢”到底慢在哪一段,直到造出一把能精确步进的尺子;
  • 我不知道新底纹该多浓,直到发现我之前一直在拿两个不同分母的 mean 互比。

所以现在这个角色更像:我负责定义“什么叫做对了”,AI 负责把它做出来、也负责帮我一起守住这个定义。 而这个定义往往一开始是不存在的——它是从“看着不对劲”里,被逼出来的一个数、一条关系、一把尺子。

剩下几条体会,和第一版一样真实,只是例子更多了:

  • 描述清楚“不对在哪”——“他看着在左右移”比“动画有 bug”有用一百倍。而“那里有个绿框,跟齿没关系,是边缘”这一句,比我说十句“再检查一下”都值钱。
  • 判断 AI 给的答案是不是在猜——同一个问题它会给出三种解释,三次都信心十足。
  • 不接受“应该好了”——每次它说“已修复”,都要重新量一遍。
  • 它会“顺手优化”你已经定稿的东西——logo 那个 ±4.2° 的摆动被改小成 ±1.6°,不是写错,是悄悄改小了,所以肉眼说不出哪里不对。重要的视觉参数要写死成不许微调的约定,并在验收时逐个对值。
  • 它的“验证”也会自欺——这是这版新加的。断言全绿不等于做对了,可能是断言压根没验到。所以我现在会要求它:先把尺子拿出来,证明这把尺子量得到东西,再去量。

相关:上一篇写了从零搭这个博客的流程(旧文已撤下);这一篇原本是那之后改版式时踩的坑,这次补上了做装置这一段。

评论

还没有人留言。你可以是第一个。

留个言