falling-leaves

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Falling Leaves

飘落的叶子

Make the falling thing read as a leaf. Reach for
ambient-section-particles
when you want a bounded atmosphere of generic motes. Reach for this when the shape has to be recognisable.
让飘落的物体看起来像真实的叶子。如果你需要的是限定区域内的通用粒子氛围,可以使用
ambient-section-particles
;而当物体形状必须可识别时,就用本方案。

Build the tumble first

先实现翻滚效果

Turn each leaf about its own long axis so it shows its face, thins to nothing edge-on, then opens out on the other side. That instant of near-disappearance is what the eye reads as "leaf". A sprite that only spins in the picture plane reads as confetti, a coin, or a paper scrap, however good the artwork is.
On 2-D canvas the tumble is a horizontal scale that crosses zero:
js
ctx.save();
ctx.translate(l.x, l.y);
ctx.rotate(l.roll);              // long axis drifting in-plane
ctx.scale(Math.cos(l.spin), 1);  // the tumble: cos crosses 0, edge-on
ctx.globalAlpha = l.alpha;
ctx.drawImage(sprite, -w / 2, -h / 2, w, h);
ctx.restore();
Drive two axes, not one.
roll
turns the leaf within the picture plane;
spin
turns it through the plane. Give each its own rate per leaf, or the motion reads as mechanical however you ease it.
In 3-D, instance quads, never point sprites. A point sprite always faces the camera and can never turn away, so it can never go edge-on. That single constraint decides the whole implementation.
让每片叶子绕自身长轴转动,使其时而展现叶面,时而侧立几乎消失,再从另一侧展开。这种近乎消失的瞬间,正是让眼睛识别出“叶子”的关键。无论素材画得多好,仅在画面平面内旋转的精灵看起来只会像彩屑、硬币或碎纸片。
在2-D Canvas中,翻滚效果通过横向缩放值跨越零来实现:
js
ctx.save();
ctx.translate(l.x, l.y);
ctx.rotate(l.roll);              // 长轴在平面内漂移
ctx.scale(Math.cos(l.spin), 1);  // 翻滚效果:cos值跨越0,呈现侧立状态
ctx.globalAlpha = l.alpha;
ctx.drawImage(sprite, -w / 2, -h / 2, w, h);
ctx.restore();
要同时驱动两个轴,而非单个轴。
roll
控制叶子在画面平面内的转动;
spin
控制叶子穿过平面的转动。为每片叶子设置独立的速率,否则无论如何缓动,动画都会显得机械。
在3-D场景中,要使用四边形实例,绝对不能用点精灵。点精灵始终朝向相机,无法转向,也就永远无法呈现侧立状态。这一个限制就决定了整个实现方案。

Couple the slip to the tumble

让滑动与翻滚联动

Drive lateral motion from the same angle as the tumble, ninety degrees out of phase. A leaf slides sideways when it knifes through the air edge-on and stalls when it presents its face flat:
js
l.x += Math.sin(l.spin) * l.slip * dt;   // fastest when cos(spin) ≈ 0
l.y += l.fall * dt;
Do not put an independent sine on
x
. It reads as wind or as an easing bug. This coupling costs one term and is what makes the path look aerodynamic.
由翻滚角度驱动侧向运动,相位差90度。叶子侧立切过空气时会向侧面滑动,叶面平展时则会停滞:
js
l.x += Math.sin(l.spin) * l.slip * dt;   // 当cos(spin)≈0时速度最快
l.y += l.fall * dt;
不要为
x
轴设置独立的正弦运动,那样看起来会像风或者缓动bug。这种联动仅需一个计算项,却能让运动轨迹看起来符合空气动力学。

Bake both faces

预渲染正反两面

Bake two sprites per colour and pick by the sign of the tumble:
js
const img = Math.cos(l.spin) < 0 ? sprite.back : sprite.face;
Make the back duller and paler than the front. Without this the leaf reads as a flat cut-out spinning; with it, as a solid object with a front and a back. It is the cheapest realism in the system.
为每种颜色预渲染两个精灵,根据翻滚的符号选择使用:
js
const img = Math.cos(l.spin) < 0 ? sprite.back : sprite.face;
让背面比正面更暗淡、更苍白。没有这个处理,叶子看起来就像一个旋转的平面剪纸;有了这个处理,它就会像一个有正反两面的立体物体。这是整个系统中成本最低的真实感优化手段。

Vary every parameter per leaf

为每片叶子设置不同参数

Randomise fall speed, tumble rate, roll rate, slip amount, phase, scale, and opacity at spawn. Share any one of them and the field stops being leaves and becomes a texture scrolling down the screen. The eye finds the common rhythm in about two seconds.
在生成叶子时,随机化下落速度、翻滚速率、转动速率、滑动幅度、相位、缩放比例和透明度。如果有任何一个参数是共享的,粒子群就不再像叶子,而是变成了向下滚动的纹理。眼睛大约两秒就能发现这种统一的节奏。

Choose where leaves come back from

选择叶子的重生位置

This decides how many leaves are actually on screen.
In 2-D, recycle across the viewport. When a leaf passes the bottom, respawn it above the top at a new random x. Wrap x as well, so wind does not empty one side.
In 3-D, recycle ahead of the camera, not around it. A band centred on the camera spends nearly all its volume behind and beside the frustum; on a 36° camera, a couple of hundred leaves put barely a dozen in frame. Drop them into a disc hung down the camera's own sight line instead and the same count appears several times over:
js
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
const cx = camera.position.x + fwd.x * AHEAD;
const cz = camera.position.z + fwd.z * AHEAD;
const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * SPREAD;
l.x = cx + Math.cos(a) * r;  l.z = cz + Math.sin(a) * r;  l.y = camera.position.y + 16;
Keep a far wrap as a backstop for a camera that walks out from under its own weather, and put it well outside the fog so nothing is seen to jump.
这决定了屏幕上实际显示的叶子数量。
在2-D场景中,在视口范围内循环复用叶子。当叶子移出底部时,在顶部上方随机x坐标处重生。同时也要对x轴进行循环处理,避免风把叶子吹到一侧导致画面失衡。
在3-D场景中,要在相机前方而非周围循环复用叶子。以相机为中心的区域,几乎所有体积都在视锥体后方和侧面;对于36°视角的相机,几百片叶子中只有十几片能出现在画面中。取而代之的是,将叶子投放到沿相机视线悬挂的圆盘区域内,相同数量的叶子会多次出现在画面中:
js
camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize();
const cx = camera.position.x + fwd.x * AHEAD;
const cz = camera.position.z + fwd.z * AHEAD;
const a = Math.random() * TAU, r = Math.sqrt(Math.random()) * SPREAD;
l.x = cx + Math.cos(a) * r;  l.z = cz + Math.sin(a) * r;  l.y = camera.position.y + 16;
保留一个远距离循环机制,作为相机移出自身粒子区域时的后备方案,并且要将其设置在雾效范围之外,避免出现跳跃的视觉效果。

Set density by band area, not by count

按区域面积设置密度,而非数量

On-screen density goes as count ÷ band area. Tighten the band before raising the count:
  • Halving the recycle radius quadruples on-screen density at the same count.
  • Doubling the count doubles draw cost for the same on-screen gain.
Scale an authored count by viewport area rather than taking it literally, or a figure that reads as a drift on desktop arrives as a blizzard on a phone:
js
const k = clamp(Math.sqrt((W * H) / (1440 * 900)), .5, 1.3);
const n = Math.round(authored * layerShare * k);
屏幕上的密度等于数量除以区域面积。在增加数量之前,先缩小区域范围:
  • 将循环半径减半,在数量不变的情况下,屏幕密度会变为原来的四倍。
  • 将数量翻倍,在屏幕密度相同的情况下,绘制成本也会翻倍。
根据视口面积缩放预设数量,而不是直接使用预设值,否则在桌面端看起来是轻柔飘落的效果,在手机上会变成暴风雪:
js
const k = clamp(Math.sqrt((W * H) / (1440 * 900)), .5, 1.3);
const n = Math.round(authored * layerShare * k);

Layer for depth

按深度分层

Use two or three layers, each with its own scale, speed, opacity, and blur:
layerscalefallopacitynote
far0.3–0.5slow0.22–0.40drawn first, may sit behind content
mid0.5–0.85medium0.46–0.78the body of the effect
near1.05–1.9fast0.50–0.82few, optionally blurred, drawn over content
Cross the near layer in front of the type. That crossing is the depth cue. Use two or three leaves there, not a curtain.
使用2到3个图层,每个图层有独立的缩放比例、速度、透明度和模糊效果:
图层缩放比例下落速度透明度说明
远景0.3–0.50.22–0.40最先绘制,可置于内容后方
中景0.5–0.85中等0.46–0.78效果主体
近景1.05–1.90.50–0.82数量少,可选择添加模糊,绘制在内容上方
让近景图层穿过文字内容。这种交叉是深度提示。近景只需放置2到3片叶子,而不是形成一道帘幕。

Handle colour and light

处理色彩与光照

  • Sample each leaf from a small ramp — deep oxblood through vermilion to dry amber — and vary saturation per leaf. One red for every leaf is the giveaway.
  • Alpha-test rather than alpha-blend for 3-D leaves so they sort correctly at any angle without a per-frame depth sort.
  • An emissive red comes back out of a tone-mapped composite pink. If leaves self-illuminate in a dark scene, drive green and blue to zero (
    0x780200
    , not
    0x8c1410
    ), or the whole fall turns candy-coloured.
  • 从一个小色阶中为每片叶子选取颜色——从深牛血红到朱红再到干琥珀色——并为每片叶子调整饱和度。所有叶子都用同一种红色是明显的破绽。
  • 3-D叶子使用Alpha测试而非Alpha混合,这样无论角度如何都能正确排序,无需逐帧深度排序。
  • **在色调映射合成中,自发光红色会呈现为粉色。**如果在黑暗场景中让叶子自发光,要将绿色和蓝色值设为零(使用
    0x780200
    而非
    0x8c1410
    ),否则整个飘落效果会变成糖果色。

Budget the cost

控制性能开销

The per-leaf update is free; a few hundred leaves of trigonometry does not register above a CPU profiler's sampling floor. The cost is fill and draw:
  • 2-D canvas: one
    drawImage
    per leaf. Pre-render the sprite once at the largest size you will draw. Never re-path the leaf per frame.
  • 3-D: one
    InstancedMesh
    , matrices composed into a shared
    Matrix4
    . Hoist scratch
    Matrix4
    /
    Quaternion
    /
    Euler
    /
    Vector3
    to module scope.
  • Alpha-tested leaves lose early-Z, so they cost more per pixel than their triangle count suggests. Prefer more, smaller leaves to fewer huge ones.
每片叶子的更新几乎没有成本;几百片叶子的三角函数计算在CPU性能分析器的采样阈值以下几乎不会被检测到。开销主要来自填充和绘制:
  • 2-D Canvas:每片叶子调用一次
    drawImage
    。预先渲染最大尺寸的精灵,不要逐帧重新绘制叶子路径。
  • 3-D:使用一个
    InstancedMesh
    ,将矩阵组合到共享的
    Matrix4
    中。将临时的
    Matrix4
    /
    Quaternion
    /
    Euler
    /
    Vector3
    提升到模块作用域。
  • Alpha测试的叶子会失去Early-Z优化,因此每像素的成本比三角形数量显示的要高。相比少量大叶子,优先选择更多的小叶子。

Stop invisible work

停止不可见的工作

  • Pause on
    document.hidden
    and when the section leaves the viewport (
    IntersectionObserver
    ). Reset
    lastTime
    on resume so the first frame after does not integrate the whole pause.
  • Clamp
    dt
    to about 1/30 s so a stall does not teleport the field.
  • Cap device pixel ratio at 2.
  • Size from a
    ResizeObserver
    on the root element, not from a one-shot measurement at script time. A page laid out later leaves the canvas 0×0 forever, because the resize event it was waiting for has already fired.
  • Guard the build against a zero viewport. Controls that wire up by running once reach the builder before first layout and otherwise spawn the whole field stacked at the origin.
  • document.hidden
    为真或元素离开视口时(使用
    IntersectionObserver
    ),暂停动画。恢复时重置
    lastTime
    ,避免恢复后的第一帧将整个暂停时间都计算进去。
  • dt
    限制在约1/30秒,避免卡顿导致粒子群瞬移。
  • 将设备像素比上限设为2。
  • 通过根元素的
    ResizeObserver
    获取尺寸,而不是在脚本运行时一次性测量。如果页面稍后才布局,Canvas会一直保持0×0,因为它等待的resize事件已经触发过了。
  • 防止视口为零的情况。一次性初始化的控件会在首次布局前调用构建器,导致所有粒子都堆叠在原点。

Respect the reader

尊重用户偏好

Under
prefers-reduced-motion: reduce
, render one still, well-composed frame and do not animate. Do not simply hide the leaves; the composition was designed with them in it. Redraw that still when a control changes so the controls still do something.
prefers-reduced-motion: reduce
时,渲染一帧构图良好的静态画面,不要播放动画。不要简单隐藏叶子;因为构图是围绕叶子设计的。当控件变化时重新绘制静态画面,确保控件仍然能起作用。

Verify

验证清单

  • Tumble crosses edge-on; leaves visibly thin and vanish once per turn
  • Slip is driven by the tumble angle, not an independent sine
  • Front and back faces differ
  • Every parameter varies per leaf
  • Recycle band is as tight as the composition allows before count goes up
  • 3-D: recycled ahead of the camera, not around it
  • 3-D: emissive reds have green and blue at zero
  • Count scales with viewport area; the readout reports what was actually built
  • Paused when hidden or off-screen;
    dt
    clamped; DPR capped at 2
  • A designed still frame under reduced motion
  • Console clean at 390px and 1440px
  • 翻滚会呈现侧立状态;叶子在每次转动中明显变薄并消失一次
  • 滑动由翻滚角度驱动,而非独立的正弦运动
  • 正反两面存在差异
  • 每个参数在叶子间都有变化
  • 在增加数量前,循环区域尽可能紧凑
  • 3-D:在相机前方循环复用叶子,而非周围
  • 3-D:自发光红色的绿色和蓝色值为零
  • 数量随视口面积缩放;输出报告实际构建的数量
  • 隐藏或离开屏幕时暂停;
    dt
    被限制;DPR上限为2
  • 在减少动画模式下显示设计好的静态画面
  • 在390px和1440px分辨率下控制台无报错