motion-quality
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMotion quality
动效质量
Every rule here was paid for. Each one is a bug that shipped, was measured on
rendered frames, and cost a user their afternoon. The numbers are real — they are
in the file so you cannot talk yourself out of the rule.
这里的每条规则都是用代价换来的。每一个都对应一个曾经发布、在渲染帧上经过测量,并让用户搭上一个下午的 bug。数字都是真实的——它们已经写进文件里,所以你没法用话术绕开这些规则。
Rule 0 — you cannot see a sub-pixel bug. Measure the frames.
规则 0——你无法肉眼看出亚像素级 bug。要去测量帧。
"It looks fine" is not a claim about a frame-accurate animation. Render the frames
and measure them. Four checks catch almost everything:
| what | why it works | what a failure looks like |
|---|---|---|
| Consecutive frames where nothing is animating must be byte-identical | the style is unchanged, so the pixels must be | non-zero diff = the renderer is non-deterministic |
| Under a monotone scale, the ink's vertical centroid must move monotonically | it is a pure affine map | direction reversals = the type is snapping to the pixel grid |
| it is a shape invariant of a rigid shape | drift = the glyph outlines are changing shape (hinting) |
| No frame should move < 0.5px while it is supposed to be animating | sub-pixel motion rasterises to an identical frame | a run of them = the animation is visibly frozen |
Recover sub-pixel geometry from the antialiasing: ,
then take intensity-weighted centroids. That is accurate to ~0.01px, which is far
finer than the bugs you are hunting.
alpha = (pixel - bg) / (fg - bg)All four checks are code now — stop re-deriving them by hand.
is the measurement library (, , ,
, , , ), pure and
tested on synthetic rasters; renders a component's
frames straight into memory (one bundle, one browser, PNG buffers, no mp4 — h264
quantises away the very antialiasing the alpha recovery reads).
lib/motion-checksettleFrameholdIsStillcentroidMonotonicshapeInvariantnoFrozenFramesedgeBleedDeltadetailCoveragelib/motion-check/capture.tsbash
pnpm run motion:measure --only=text-build # -> registry/__measured__.jsonIt also answers the two questions no test could: when a component settles
(a beat still animating when the next fades in over it — @remotion/transitions
overlaps 18 frames), and how much copy it can hold before the line runs off
the frame.
If someone says it is still not smooth and your metric says it is fixed, your
metric is measuring the wrong thing. Do not argue with them. Go find the metric
that reproduces what they see. Two separate bugs in this repo were missed exactly
this way.
“看起来没问题”不能作为帧精确动画的依据。渲染并测量这些帧。下面四项检查几乎可以发现所有问题:
| 检查项 | 有效原因 | 失败表现 |
|---|---|---|
| 没有任何内容处于动画状态时,连续帧必须逐字节完全一致 | style 没有变化,因此像素也必须相同 | 差异非零 = renderer 不具备确定性 |
| 在单调 scale 下,ink 的垂直质心必须单调移动 | 它是一个纯 affine map | 方向发生反转 = 文字正在吸附到 pixel grid |
在 scale 变化时, | 这是刚性形状的形状不变量 | 数值漂移 = glyph outlines 正在改变形状(hinting) |
| 本应处于动画状态时,任何一帧的移动量都不应小于 0.5px | 亚像素运动会栅格化成完全相同的帧 | 连续出现这种情况 = 动画看起来已经冻结 |
从抗锯齿信息中恢复亚像素几何结构:,然后计算以 intensity 为权重的 centroid。其精度约为 ~0.01px,远比你要排查的问题精细。
alpha = (pixel - bg) / (fg - bg)这四项检查现在都已经写成代码——别再手工重新推导。 是测量库(、、、、、、),它是 pure 的,并已在 synthetic rasters 上完成测试; 会将组件的帧直接渲染到内存中(一个 bundle、一个 browser、PNG buffers,不使用 mp4——h264 会量化掉 alpha 恢复所要读取的抗锯齿信息)。
lib/motion-checksettleFrameholdIsStillcentroidMonotonicshapeInvariantnoFrozenFramesedgeBleedDeltadetailCoveragelib/motion-check/capture.tsbash
pnpm run motion:measure --only=text-build # -> registry/__measured__.json它还回答了两个测试无法回答的问题:组件何时 settle(下一段内容淡入并覆盖其上时,前一段仍在动画——@remotion/transitions 会重叠 18 帧),以及一行文字在溢出画面之前能够容纳多少 copy。
如果有人说它仍然不流畅,而你的 metric 表示问题已经修复,那么你的 metric 测量错了对象。 不要争论。去找一个能够复现他们所见效果的 metric。这个仓库中的两个独立 bug 正是因为这种做法而被漏掉。
The two bugs that make scaled text look cheap
让缩放文本显得廉价的两个 bug
Browsers do not scale text the way they scale an image. They re-shape and
re-rasterise every glyph at the new size. Two things go wrong, they are
independent, and you need both fixes.
浏览器缩放文本时,不会像缩放图像那样处理。它们会以新的尺寸重新塑形并重新 rasterise 每个字形。这里会发生两个彼此独立的问题,而两种修复都需要采用。
1. "Stuck" — glyph origins snap to whole pixels vertically
1.“卡住”——字形 origin 在垂直方向吸附到整数像素
The rasteriser quantises each glyph's origin: quarter-pixel precision
horizontally, and none at all vertically — it rounds to a whole device pixel
(Skia, ). So a scale that moves
the baseline makes the type climb the pixel grid in whole-pixel jumps. At the
slow ends of an eased curve the baseline drifts a fraction of a pixel per frame,
which rounds to nothing for several frames and then to a whole pixel all at once.
Sit still, jump, sit still.
SkGlyphPositionRoundingSpec::HalfAxisSampleFreqFix: pivot the scale on the baseline. Then the baseline's device Y never
changes and there is nothing to snap.
tsx
// measure it — never guess it from a line-height ratio
<span ref={lineRef} style={{ transformOrigin: `0% ${baseline}px`, scale }}>
{…}
{/* an empty zero-sized inline-block sits ON the baseline */}
<span ref={baselineRef} style={{ display: "inline-block", width: 0, height: 0 }} />
</span>
// baseline = baselineRef.current.offsetTopMeasured, sweeping across a linear 1.6x → 1x ramp:
transformOrigin| pivot | vertical judder | direction reversals (40 frames) |
|---|---|---|
| 0.284px | 29 |
| 74% | 0.273px | 6 |
| 80% (the baseline) | 0.014px | 0 |
| 90% | 0.284px | 0 |
A sharp minimum exactly on the baseline and nowhere else. Per-letter transforms
need the same pivot — is the bottom of the box, which sits below the
baseline by the descent, so it drags the letter's baseline as it scales.
50% 100%rasteriser 会量化每个字形的 origin:水平方向的精度为四分之一像素,垂直方向则完全没有精度——它会直接舍入到整个 device pixel(Skia,)。因此,移动 baseline 的 scale 会让文字沿 pixel grid 以整像素为单位跳动。在 eased curve 的缓速阶段,baseline 每帧只漂移不到一个像素,于是连续数帧都被舍入为 0,随后又突然跳到整个像素。停住、跳动、再停住。
SkGlyphPositionRoundingSpec::HalfAxisSampleFreq修复:以 baseline 作为 scale 的 pivot。 这样 baseline 的 device Y 永远不会改变,也就没有可供吸附的位移。
tsx
// measure it — never guess it from a line-height ratio
<span ref={lineRef} style={{ transformOrigin: `0% ${baseline}px`, scale }}>
{…}
{/* an empty zero-sized inline-block sits ON the baseline */}
<span ref={baselineRef} style={{ display: "inline-block", width: 0, height: 0 }} />
</span>
// baseline = baselineRef.current.offsetTop在线性 1.6x → 1x 变化过程中扫过不同的 ,实测结果如下:
transformOrigin| pivot | 垂直 judder | 方向反转次数(40 帧) |
|---|---|---|
| 0.284px | 29 |
| 74% | 0.273px | 6 |
| 80%(baseline) | 0.014px | 0 |
| 90% | 0.284px | 0 |
最小值恰好出现在 baseline 上,而且其他位置都没有这么低。逐字母 transform 也需要使用相同的 pivot—— 是 box 的底部,它会因 descent 而位于 baseline 下方,因此会在缩放时拖动字母的 baseline。
50% 100%2. "Shake" — hinting boils the letterforms
2.“抖动”——hinting 会让字形不断变化
Hinting bends each glyph's outline so its stems land on whole pixels. As the size
slides, every stem re-snaps to a different grid and the letters literally change
shape frame to frame. They boil.
Fix: . It turns hinting off and renders the
outline as it actually is.
text-rendering: geometricPrecisionMeasured over a fall-back, using the shape invariant that cannot change:
| shape drift | |
|---|---|
| hinting on (default) | 3.41% |
| 0.22% |
Fifteen times steadier. The type reads very slightly softer without hinting. That
is not blur — it is the absence of a lie, and it is what every professional
motion tool does with type. It also forces sub-pixel glyph positioning on, which
Blink otherwise only enables above a device scale factor of 1 — and a Remotion
render is exactly 1, so on Linux (where renders actually run) both axes snap to
whole pixels without it.
Hinting 会弯曲每个字形的 outline,让 stem 对齐到整数像素。随着字号变化,每个 stem 都会重新吸附到不同的 grid,字母甚至会逐帧改变形状。这种效果被称为 boil(沸腾抖动)。
修复:使用 。 它会关闭 hinting,并按照 outline 的真实形态进行渲染。
text-rendering: geometricPrecision在 fallback 字体上实测,使用不会发生变化的 shape invariant:
| 设置 | shape drift |
|---|---|
| hinting 开启(默认) | 3.41% |
| 0.22% |
稳定性提升了十五倍。关闭 hinting 后,文字会显得稍微柔和一些。那不是模糊——而是不再呈现一种虚假的形态,这也是每个专业 motion tool 处理文字时会采用的方式。它还会强制开启亚像素字形定位;否则 Blink 只会在 device scale factor 大于 1 时启用该功能,而 Remotion render 的 device scale factor 正好是 1,因此在 Linux(也就是实际执行 render 的环境)中,不使用它就会让两个轴都吸附到整数像素。
will-change: transform
— right for the Player, wrong for the render
will-change: transformwill-change: transform
——对 Player 正确,对 render 错误
will-change: transformIt hands the scale to the compositor, which resamples a bitmap instead of
re-rasterising real type. That is the correct trade in one place and the wrong
trade in the other:
- A render is spread across parallel browser tabs, and each tab inherits a stale raster from whatever scale it drew last. Measured: frames whose style was byte-identical came out as 4 different rasterisations, and ~9,000 pixels changed per frame during a hold where nothing was moving. The text shimmers while standing still. And the type is a rescaled texture, not type.
- The Player is one continuous tab with a hard ~8ms frame budget at 120Hz. Re-shaping a line of type at a brand-new size every frame is the most expensive thing you can ask a browser to do per frame. Measured on a single continuous tab: 0.016px judder, no loss of sharpness at all.
So gate it:
tsx
import { getRemotionEnvironment } from "remotion";
...(getRemotionEnvironment().isRendering ? null : { willChange: "transform" as const }),它会把 scale 交给 compositor 处理,从而重新采样 bitmap,而不是重新 rasterise 真正的文字。在一种场景中,这是正确的取舍;在另一种场景中,则是错误的取舍:
- render 分布在多个并行 browser tabs 中,每个 tab 都会沿用上一次所绘制 scale 对应的 stale raster。实测显示:style 逐字节完全一致的帧,最终却产生了 4 种不同的 rasterisation;在什么都没动的 hold 期间,每帧约有 9,000 个像素发生变化。文字静止时依然在闪烁,而且呈现出来的是缩放后的纹理,而不是真正的文字。
- Player 是单个持续运行的 tab,在 120Hz 下有约 8ms 的硬性帧预算。每帧都以全新尺寸重新塑形一行文字,是浏览器单帧开销最大的操作之一。在单个持续运行的 tab 上实测:judder 为 0.016px,完全没有损失清晰度。
因此需要按环境进行控制:
tsx
import { getRemotionEnvironment } from "remotion";
...(getRemotionEnvironment().isRendering ? null : { willChange: "transform" as const }),Cargo cult — these are not fixes
Cargo cult——这些并不是修复方案
translateZ(0)translate3d(…, 0)backface-visibility: hiddenperspective(1px)rotateY(0.01deg)filter: blur(0px)-webkit-font-smoothing: antialiasedfont-sizescaletranslateZ(0)translate3d(…, 0)backface-visibility: hiddenperspective(1px)rotateY(0.01deg)filter: blur(0px)-webkit-font-smoothing: antialiasedfont-sizescaleEasing on a frame clock
帧时钟上的 easing
Quint-out and expo-out — the curves everyone reaches for — cover 99% of their
travel in the first third and then crawl. On a frame clock that crawl is not a
graceful settle, it is identical frames.
Measured on a 50px rise at 30fps with : 5 of its
14 frames moved less than half a pixel each. They rasterised identically. The
word visibly stopped dead partway up and waited.
cubic-bezier(0.22, 1, 0.36, 1)A settle worth one frame is a settle. A settle worth five frames is a freeze.
Use a moderate decelerate — — and check every
curve against the < 0.5px test above. Curves should still arrive at a standstill;
they just must not spend frames on travel you cannot see.
cubic-bezier(0.2, 0.6, 0.35, 1)Quint-out 和 expo-out 是大多数人最先想到的曲线——它们在前三分之一行程内就完成了 99% 的位移,随后才开始缓慢爬行。在帧时钟上,这种爬行并不是优雅的 settle,而是完全相同的帧。
在 30fps 下使用 让 50px 的内容上升,实测结果是:14 帧中有 5 帧的移动量不到半像素。这些帧的栅格化结果完全相同。这个词在上升途中明显骤然停住,然后原地等待。
cubic-bezier(0.22, 1, 0.36, 1)值得一帧的 settle 才叫 settle。值得五帧的 settle 就是冻结。
使用适度的减速曲线————并根据上面的 < 0.5px 测试检查每条曲线。曲线仍然应当抵达静止状态,只是不要把帧浪费在肉眼无法看见的位移上。
cubic-bezier(0.2, 0.6, 0.35, 1)Layout gotchas that will bite you
迟早会咬到你的布局陷阱
- A trailing space inside an gets stripped. It sits at the end of that box's line and CSS removes it, so per-word spans render as
inline-block. Put the separator between the spans, not inside them.Noextracharge - /
offsetWidthare layout px — unaffected by transforms, which is exactly why they are the right tool for measuring a scene you are about to transform. ButoffsetLeftis rounded to an integer; useoffsetTopwhen you need sub-pixel (and remember it is affected by transforms).getBoundingClientRect() - Measure once, behind , and
delayRender()only after the measurement has re-rendered — otherwise frame 0 is captured with the wrong geometry.continueRender() - Transforms do not reflow. Animate /
scale/translateand nothing else, so the baseline is fixed and there is no layout shift.opacity
- 末尾的空格会被移除。 空格位于该 box 所在行的末尾,CSS 会将其删除,因此逐单词 spans 会渲染成
inline-block。应将分隔符放在 spans 之间,而不是内部。Noextracharge - /
offsetWidth是布局 px——不受 transform 影响,因此正适合测量即将执行 transform 的 scene。但offsetLeft会被舍入为整数;需要亚像素精度时应使用offsetTop(同时记得它会受到 transform 影响)。getBoundingClientRect() - 在 后面测量一次,并且只有在测量所触发的重新渲染完成后才能调用
delayRender()——否则第 0 帧会使用错误的 geometry 被捕获。continueRender() - Transform 不会触发 reflow。 只动画 /
scale/translate,不要动画其他属性,这样 baseline 会保持固定,也不会产生 layout shift。opacity
The live Player lies. Some components must ship a rendered demo.
实时 Player 会骗人。有些组件必须附带渲染后的 demo 发布。
A is not a video — it is React re-rendering on
in real time. A 30fps composition on a 120Hz display must hold every frame for
exactly four refreshes; a frame that misses its budget is shown for the wrong
length of time, and the eye reads that as sticking and shaking. It is worst
during slow smooth motion, which is exactly where a title reveal lives.
<Player>requestAnimationFrameFor those scenes the preview makes a correct component look broken. The answer
is to stop previewing an approximation: add the slug to in
and the site plays the real mp4 instead. See CONTRIBUTING.md.
RENDERED_DEMOSlib/rendered-demos.tsxIf you change a component in , re-render its demo in the same
change — . Nothing catches a stale one.
RENDERED_DEMOSpnpm run render:previews --only <slug>Watch for identifier vs. label when wiring previews: takes both
(the registry key) and (a human label). Passing "Text Swell" where a
slug was wanted made the lookup miss silently and fall back to the stuttery Player,
and it looked exactly like the feature was never wired.
PreviewStageslugname<Player>requestAnimationFrame对于这些场景,preview 会让一个本来正确的组件看起来像是坏了。答案是不再预览近似效果:将 slug 添加到 的 中,网站就会改为播放真实的 mp4。参见 CONTRIBUTING.md。
lib/rendered-demos.tsxRENDERED_DEMOS如果修改了 中的组件,必须在同一次变更中重新渲染它的 demo——执行 。没有任何机制能够发现过期的 demo。
RENDERED_DEMOSpnpm run render:previews --only <slug>配置 preview 时要留意 identifier 与 label 的区别: 同时接收 (registry key)和 (供人阅读的 label)。在需要 slug 的位置传入“Text Swell”,会导致查找静默失败,并回退到卡顿的 Player,看起来就像功能根本没有接入。
PreviewStageslugnameA render has no global CSS. The site does.
Render 没有全局 CSS,但网站有。
A Remotion bundle loads none of the app's stylesheets, so a component can be
verified frame by frame in an mp4 and still be invisible in the app that installs
it. This is not hypothetical — it shipped:
Tailwind's preflight sets . It applies to and
not to . A mark placed in an absolutely-positioned box at has
(containing block − left) = 0 available width, so the box is shrink-to-fit and
asks its content for a minimum. An answers "58" and holds it
open. An under preflight answers "I can be 0" — the box collapses, 100%
of 0 is 0, and the image renders zero pixels wide. The rendered mp4 was perfect.
The docs page was a blank white rectangle, and the sat there looking
stuck.
img, video { max-width: 100% }imgsvgleft: 100%<svg width={58}><img><Player>So: when a component takes an image, verify it on the site, not only in a render.
Load the page and read back the element's real geometry —
on the said , which named the bug
instantly after a lot of guessing did not. And defend the component: on any image you size yourself, on any shrink-to-fit box
you position at an edge.
getBoundingClientRect()<img>0 x 17max-width: nonewidth: max-contentRemotion bundle 不会加载应用的 stylesheet,因此某个组件即使能够在 mp4 中逐帧验证,也仍然可能在安装它的应用中完全不可见。这并非假设性问题——它已经实际发生过:
Tailwind 的 preflight 设置了 。它适用于 ,但不适用于 。将一个标记放在 的绝对定位 box 中时,(containing block − left) = 0,可用宽度为 0,因此该 box 会采用 shrink-to-fit,并要求其内容提供一个最小宽度。 会回答“58”,从而保持自身宽度;而处于 preflight 下的 可以回答 “我可以宽 0”——box 随即塌缩,100% 的 0 仍然是 0,最终图片的渲染宽度为 0 像素。
img, video { max-width: 100% }imgsvgleft: 100%<svg width={58}><img>render 出来的 mp4 完全正常,文档页面却是一片空白矩形, 看起来也像是卡住了。
<Player>因此:当组件使用图片时,必须在网站上验证,而不能只验证 render。 加载页面并读回元素的真实 geometry——对 调用 得到 ,在反复猜测之后,这个结果立刻指出了问题所在。同时也要保护组件:对于自行设置尺寸的图片,使用 ;对于定位在边缘的 shrink-to-fit box,使用 。
<img>getBoundingClientRect()0 x 17max-width: nonewidth: max-contentPerspective rushes fill the frame in one frame unless you plan for it
如果不提前规划,perspective 会在一帧内迅速铺满画面
Under , every size threshold near the top is crossed
within a frame or two of every other one. Gate a "now cover the screen" event on the
mark exceeding the frame diagonal and you get 23% covered → 91% covered in a
single frame: a cut, not a fill. Open the gate earlier — when the mark is merely
taller than the frame — and the same event gets five or six frames to happen in,
without ever being able to start while the mark is small.
scale = 1 / (1 - travel)And a logo cannot cover a frame by growing: scaling it scales its holes by
exactly as much. Measured on a 28%-ink mark, solid-ink coverage needed 396x. The
ending has to be the mark's colour flooding out from inside it — and it must be a
shape (a hard-edged disc born inside the mark's own ink), never an opacity ramp.
Fading a full-frame rectangle up leaves a half-transparent wash lying over the
backdrop for several frames, and there is no reading of that except "the screen faded
to blue".
在 下,靠近顶部的每个尺寸阈值都会在彼此相差一两帧的时间内被跨越。如果将“现在覆盖屏幕”事件绑定到标记超过画面对角线的条件上,覆盖率会在单帧内从 23% 跃升至 91%:这是剪切,而不是填充。
scale = 1 / (1 - travel)应该更早打开这个 gate——当标记仅仅高于画面时就触发。这样,同一事件便有五六帧时间逐步完成,同时也绝不会在标记还很小的时候就开始。
此外,logo 无法通过放大来覆盖整个画面:缩放 logo 时,其内部空洞也会按完全相同的比例放大。对 ink 占比为 28% 的标记进行实测,要实现完整的 solid-ink 覆盖,需要放大 396x。
结尾必须让标记的颜色从其内部向外扩散,而且必须是一种形状(一个从标记自身 ink 中诞生的 hard-edged disc),绝不能使用 opacity ramp。淡入一个覆盖全画面的矩形,会在背景上留下持续数帧的半透明色块;这种效果除了“屏幕淡成蓝色”之外,没有任何合理的解读。
Repo gotchas
仓库中的陷阱
- Never run a formatter over . It reflows the whole file and buries your one entry in hundreds of lines of noise.
registry/snap-cn/registry.json - rewrites every
pnpm run registry:build, not just yours. If other components have uncommitted source edits, their outputs get regenerated too. Checkpublic/r/*.jsonand say so.git status - A re-rendered demo does not mean a re-rendered demo. keeps the same filename forever, and a browser that already has one will keep replaying it —
public/demos/<slug>.mp4caches hardest of all. That cost three rounds of "you didn't fix it" against builds that no longer existed. It is fixed properly now:<video>hashes every demo intorender:previewsandlib/demo-manifest.jsonputs that hash in the URL (renderedDemoSrc()), so a re-rendered demo is a different address and a stale one is not something a cache can serve.?v=<hash>also sendsnext.config.tsforno-storein dev. If you re-render a demo by hand, re-run/demos/*so the manifest moves with it — a stale manifest is a stale demo. And when someone says "you didn't fix it": check what is on disk (render:previews) and what the server actually serves before you believe them, and before you disbelieve them.md5 - Match the reference, do not eyeball it. When a user gives you a reference
video, extract the frames and measure the actual curves (+ threshold the ink + track bounding boxes). Every choreography detail in
ffmpegandtext-reveal— the forward push, the two clocks, the letter swell, which words bounce — came out of measuring the reference, and every one of them was invisible to the naked eye.text-swell
- 绝不要对 运行 formatter。 它会重排整个文件,并将你新增的唯一条目淹没在数百行噪声之中。
registry/snap-cn/registry.json - 会重写所有
pnpm run registry:build,而不只是你修改的文件。如果其他组件存在尚未提交的源码修改,它们的输出也会被重新生成。检查public/r/*.json,并明确说明这一点。git status - 重新渲染了 demo,并不等于用户看到的 demo 已经重新渲染。 的文件名永远保持不变,而浏览器中已经存在的文件会继续被重复播放——
public/demos/<slug>.mp4的缓存尤其顽固。这让已经不复存在的构建版本白白经历了三轮“你没有修复它”的追查。现在已经 properly fix:<video>会为每个 demo 计算 hash 并写入render:previews,lib/demo-manifest.json会把该 hash 放入 URL(renderedDemoSrc()),因此重新渲染后的 demo 会对应一个不同地址,缓存无法再提供旧版本。?v=<hash>在开发环境中也会为next.config.ts发送/demos/*。 如果手动重新渲染了 demo,请再次运行no-store,让 manifest 同步更新——manifest 过期就意味着 demo 过期。当有人说“你没有修复它”时,先检查磁盘上的内容(render:previews)以及服务器实际返回的内容,再决定相信或不相信对方。md5 - 匹配 reference,不要凭肉眼判断。 当用户提供 reference video 时,应提取帧并测量实际曲线(+ 对 ink 进行 threshold + 跟踪 bounding boxes)。
ffmpeg和text-reveal中的每一个编排细节——向前推进、两个时钟、字母 swell,以及哪些单词会产生弹跳——都来自对 reference 的测量,而且每一个细节都无法用肉眼察觉。text-swell