这篇文章先放原始 Skill,再逐个模块解释。原始作品:The no-GL grid · 在线体验:LUNA Explore
原始 Skill(完整内容)
下面是从作者地址读取的 no-gl-grid-skill.md 原文,保留原始顺序和内容。后文按照这份 Skill 的模块逐个拆解。
001 | ---
002 | name: no-gl-grid
003 | description: An infinite, domed, draggable image grid with zero WebGL and zero dependencies — plain DOM/JS/CSS, framework-agnostic (vanilla, Vue, React, Svelte — anything that renders elements). One matrix3d per card, modulo wrap, pincushion warp via rect→quad homography, cursor bulge, mask-image flashlight, and a FLIP-style card zoom. Use when building an infinite grid / infinite canvas without libraries, or when asked for "the no-GL grid".
004 | ---
005 |
006 | # The no-GL infinite grid
007 |
008 | Everything is DOM. The grid holds just enough cards to tile the viewport
009 | once, and every visual — the dome, the cursor bulge, the card flip — is a
010 | single `matrix3d` written per card, per frame, only while something moves.
011 | Steady 120fps with images, captions and a flashlight overlay.
012 |
013 | **Zero dependencies, framework-agnostic.** The whole system is plain
014 | JS + CSS on ordinary elements: no WebGL, no animation library, no smooth-
015 | scroll library, no framework requirement. The reference build happens to be
016 | Vue + Tailwind, but nothing below relies on either — render the cards with
017 | anything (or `innerHTML`), keep the per-frame writes in vanilla JS exactly
018 | as shown, and express the static styles however the host project does.
019 |
020 | ## The pipeline
021 |
022 | For every visible card, every animated frame:
023 |
024 | 1. **Wrap** its position around the grid's period (modulo — infinite pan).
025 | 2. **Warp** its period-box corners (pincushion + bulge — plain math on 4 points).
026 | 3. **Inset** the warped quad by half the gap (constant gutters at any warp).
027 | 4. **Solve** the rect→quad homography and write it as one `matrix3d`.
028 |
029 | New effects are new terms in step 2. Nothing downstream ever changes.
030 |
031 | ## Layout
032 |
033 | - Container: `fixed inset-0 overflow-hidden touch-none select-none`.
034 | - Grid: CSS grid, `w-max`, gap from one CSS var; column width `--card` set
035 | from JS: `card = viewportWidth / visibleColumns − gap`.
036 | - Tile `visibleColumns + 2` columns and `ceil(vh / period) + 2` rows. The
037 | `+2` is load-bearing: a wrapping cell lingers a full cell past one edge
038 | before its window flips it across, so the slack per axis must hold at
039 | least one whole cell — with `+1`, the far side runs dry for half of all
040 | scroll phases and rim cards visibly pop.
041 | - Measure every card once per resize (`getBoundingClientRect` at reset
042 | transform); cache `el`, the `img`, rect edges and centre.
043 |
044 | ## Wrap — infinite in both axes
045 |
046 | ```js
047 | const wrap = (min, max, v) => {
048 | const r = max - min
049 | return min + (((v - min) % r + r) % r)
050 | }
051 |
052 | // per cell: max = grid box + one trailing gap (= cols × (card + gap))
053 | // margin = (max − viewport) / 2 → spare coverage split evenly
054 | const tx = wrap(cell.right + margin.x - max.x, cell.right + margin.x, x.current)
055 | ```
056 |
057 | Only cards in view get written. Off-screen cards receive one parking write,
058 | then are skipped until they wrap back in. Pad the visibility test by one
059 | period: at drag speed a card crosses the rim between two frames, and with
060 | no slack its first write lands already inside the viewport — a pop instead
061 | of a slide.
062 |
063 | ## The warp — dome + bulge on four points
064 |
065 | ```js
066 | const warp = (px, py) => {
067 | const nx = px / halfW, ny = py / halfH
068 | const f = 1 + BOW * (nx * nx + ny * ny) // pincushion: rim spreads
069 |
070 | let wx = px * f, wy = py * f
071 |
072 | const dx = wx - cursor.x, dy = wy - cursor.y // gaussian shove away
073 | const push = BULGE * bulgeWeight * Math.exp(-(dx * dx + dy * dy) / reach2)
074 |
075 | return { x: wx + dx * push, y: wy + dy * push }
076 | }
077 | ```
078 |
079 | Warp the four corners of the card's PERIOD box (card + half gap all round).
080 | Neighbouring cards share those lattice points exactly, so the bow is
081 | seamless. Then inset each corner by `gap / 2` along its own two edges —
082 | both neighbours inset from the same shared line, so the gap stays exactly
083 | constant at any warp strength.
084 |
085 | ## Rect → quad: the matrix3d
086 |
087 | A homography maps a rectangle onto ANY straight-edged quad — keystone, skew
088 | and scale all fall out of one solve. Unit-square solution, pre-scaled by the
089 | card size, translation carried in the matrix (transform-origin: top left):
090 |
091 | ```js
092 | const matrix = (w, q0, q1, q2, q3) => { // tl, tr, bl, br in local px
093 | const dx1 = q1.x - q3.x, dy1 = q1.y - q3.y
094 | const dx2 = q2.x - q3.x, dy2 = q2.y - q3.y
095 | const sx = q0.x - q1.x - q2.x + q3.x
096 | const sy = q0.y - q1.y - q2.y + q3.y
097 | const den = dx1 * dy2 - dx2 * dy1
098 | const g = den ? (sx * dy2 - dx2 * sy) / den : 0
099 | const h = den ? (dx1 * sy - sx * dy1) / den : 0
100 | const a = q1.x - q0.x + g * q1.x, b = q2.x - q0.x + h * q2.x
101 | const d = q1.y - q0.y + g * q1.y, e = q2.y - q0.y + h * q2.y
102 |
103 | return `matrix3d(${a / w}, ${d / w}, 0, ${g / w}, ${b / w}, ${e / w}, 0, ${h / w}, 0, 0, 1, 0, ${q0.x}, ${q0.y}, 0, 1)`
104 | }
105 | ```
106 |
107 | No `perspective` on any container, no `transform-style` — the matrix
108 | carries its own projection and cards stay flat in z.
109 |
110 | ## Motion
111 |
112 | One self-owned rAF. Frame-rate-independent damping, dt clamped for the
113 | tab-switch frame; a still-check skips all writes when nothing moves:
114 |
115 | ```js
116 | const damp = (from, to, rate, dt) =>
117 | from + (to - from) * (1 - Math.pow(1 - rate, dt * 60))
118 |
119 | x.current = damp(x.current, x.target, 0.1, dt)
120 | ```
121 |
122 | Inputs: `wheel` (+= deltas), pointer drag (target = origin − travel × speed),
123 | keyboard (arrows nudge one cell = card + gap, space pans ~80% of a viewport,
124 | shift+space back, Escape closes the flip), cursor damped separately for the
125 | bulge. Suppress the bulge by velocity — `calm = 1 / (1 + |lag| / 40)` — so
126 | the lens fades while the grid travels.
127 |
128 | Lock the document so mobile browser chrome never reacts: `overflow: hidden`
129 | and `overscroll-behavior: none` on html and body, `position: fixed; inset: 0`
130 | on body (old iOS ignores overflow on body for touch), `touch-action: none`
131 | on the stage, and a no-zoom viewport meta.
132 |
133 | ## The flip (card zoom)
134 |
135 | Not a separate animation system: flip progress (damped 0→1) lerps the
136 | clicked card's quad corners from their live grid positions toward a centred
137 | square, through the same inset + homography. The in-betweens un-keystone
138 | naturally; the return leg lands on the moving grid slot for free. Side
139 | panel and caption fades are remaps of the same progress value.
140 |
141 | Layout details that matter:
142 |
143 | - The white side panel is a CHILD of every card, parked exactly under the
144 | image, slid out by a `--slide` var the flip writes (one cell, one var —
145 | cheap). As card DOM it inherits the matrix, so it warps and travels along.
146 | - The image↔panel gap is NEGATIVE by twice the border radius, in CARD-LOCAL
147 | units (the matrix scales the radius up with the card), so the panel's own
148 | rounded corners hide behind the image and its edge reads straight.
149 | - Portrait (`wh > ww`): pair vertically instead — the panel slides out
150 | underneath and the image takes most of the width. The unwarped text box
151 | over the panel gets an absolute top inset equal to the overlap, so
152 | scrolled text clips at the image edge instead of riding under it.
153 | - While the card sits above the dim overlay (z lift), give it a per-card
154 | dim stand-in computed from the same falloff at its position — set BEFORE
155 | the z lift — so both hand-offs are invisible.
156 | - Hold a full-viewport cover (plain div, class-driven opacity fade) until
157 | every image has settled, with a timeout cap so a slow network can't hang
158 | the reveal.
159 |
160 | ## The flashlight
161 |
162 | A solid dim layer with the hole punched by `mask-image`, positioned by two
163 | CSS vars written per frame (whole pixels, change-gated). Sample the stops
164 | from a smoothstep — a two-stop gradient ramps alpha linearly and the equal
165 | 8-bit steps read as rings:
166 |
167 | ```js
168 | const smooth = t => t * t * (3 - 2 * t)
169 | // 12 stops: rgba(0,0,0,smooth(i/12)) at hole + (i/12) * (edge − hole)
170 | ```
171 |
172 | Emit the stop positions in rem (value ÷ root font size) so they follow a
173 | fluid type scale between resizes. On touch there is no cursor to chase, so
174 | rest the hole at the centre and double its radius — it reads as a soft
175 | vignette. Keep the same `smooth()` feeding any per-card dim math so
176 | hand-offs match the overlay exactly.
177 |
178 | ## Performance rules
179 |
180 | - Write `element.style.transform` directly. NEVER per-frame CSS variables
181 | on cells: a custom property invalidates style for the whole subtree each
182 | frame — across a grid of cards it shows as visible image flicker.
183 | - Only in-view cards get writes; parked cards cost nothing.
184 | - Per-card image parallax = direct transform on the `<img>` with a scale
185 | bleed (`scale(1.1)`, bleed ≥ max drift).
186 | - Opacity and transform only — nothing that lays out or paints per frame.
187 | - Safari: a 3D-transformed `<img>` can be sorted above later absolutely
188 | positioned siblings and swallow captions — give the overlays an explicit
189 | `z-index` and their own `translateZ(0)` layer.
190 | - Guard `resize()` with a generation counter: rotates fire several resizes
191 | that interleave at the re-measure await, and a stale run finishing last
192 | overwrites fresh geometry.
193 |
194 | ## Tunables
195 |
196 | | knob | feel |
197 | | --- | --- |
198 | | `BOW 0.15` | pincushion strength (rim spreads 1 + BOW) |
199 | | `BULGE 0.25 / REACH 1.5` | lens strength / radius in periods |
200 | | `FLIP 0.7` | flipped card, fraction of viewport short side |
201 | | damp rates `0.1–0.14` | scroll glide / flip speed |
202 | | `SHADE 0.7 / TORCH 0.75` | dim strength / hole radius in periods |
模块 1:Metadata 与适用场景
对应原文:
---
name: no-gl-grid
description: An infinite, domed, draggable image grid with zero WebGL and zero dependencies — plain DOM/JS/CSS, framework-agnostic (vanilla, Vue, React, Svelte — anything that renders elements). One matrix3d per card, modulo wrap, pincushion warp via rect→quad homography, cursor bulge, mask-image flashlight, and a FLIP-style card zoom. Use when building an infinite grid / infinite canvas without libraries, or when asked for "the no-GL grid".
---
讲解:
前 3 行是 Skill 的路由元数据。name 给 Agent 一个稳定标识;description 同时声明能力和边界:无限、圆顶、可拖动、零 WebGL、零依赖、单卡 matrix3d。这些约束很重要,因为它把需求从“做一个好看的网格”收敛成“用普通 DOM 完成几何变形”。
使用场景是无限 grid / infinite canvas,或者用户明确说 “the no-GL grid”。它不应该被用于普通轮播或只需要 CSS 横向滚动的页面。
模块 2:Everything is DOM
对应原文:
# The no-GL infinite grid
Everything is DOM. The grid holds just enough cards to tile the viewport
once, and every visual — the dome, the cursor bulge, the card flip — is a
single `matrix3d` written per card, per frame, only while something moves.
Steady 120fps with images, captions and a flashlight overlay.
**Zero dependencies, framework-agnostic.** The whole system is plain
JS + CSS on ordinary elements: no WebGL, no animation library, no smooth-
scroll library, no framework requirement. The reference build happens to be
Vue + Tailwind, but nothing below relies on either — render the cards with
anything (or `innerHTML`), keep the per-frame writes in vanilla JS exactly
讲解:
原文首先确定渲染边界:卡片、图片、标题和说明都是普通 DOM;框架只负责一次性生成内容;高频动画直接写元素的 style.transform。因此不需要 WebGL,也不需要 GSAP、smooth-scroll 等动画依赖。
“zero dependencies”描述的是核心几何和动画实现,不是说宿主网站不能使用 Vue、React 或 Astro。LUNA 的 /explore 用 Astro 生成卡片,再由浏览器端脚本接管运动。
模块 3:Pipeline
对应原文:
## The pipeline
For every visible card, every animated frame:
1. **Wrap** its position around the grid's period (modulo — infinite pan).
2. **Warp** its period-box corners (pincushion + bulge — plain math on 4 points).
3. **Inset** the warped quad by half the gap (constant gutters at any warp).
4. **Solve** the rect→quad homography and write it as one `matrix3d`.
New effects are new terms in step 2. Nothing downstream ever changes.
讲解:
每一帧都遵循固定的四步流水线:
Wrap → Warp → Inset → Solve
Wrap 把无限位移折回有限周期;Warp 改变周期盒子的四个角;Inset 沿边向内收半个 gap;Solve 用 homography 把原矩形写成一个 matrix3d。
这个顺序是核心设计。新效果应该加在 Warp 阶段,后面的间距处理和矩阵求解无需改动。
模块 4:Layout
对应原文:
## Layout
- Container: `fixed inset-0 overflow-hidden touch-none select-none`.
- Grid: CSS grid, `w-max`, gap from one CSS var; column width `--card` set
from JS: `card = viewportWidth / visibleColumns − gap`.
- Tile `visibleColumns + 2` columns and `ceil(vh / period) + 2` rows. The
`+2` is load-bearing: a wrapping cell lingers a full cell past one edge
before its window flips it across, so the slack per axis must hold at
least one whole cell — with `+1`, the far side runs dry for half of all
scroll phases and rim cards visibly pop.
- Measure every card once per resize (`getBoundingClientRect` at reset
transform); cache `el`, the `img`, rect edges and centre.
讲解:
布局不是只计算刚好可见的卡片,而是横纵都增加 +2 的缓冲单元。这样卡片完整离开视口后才会被折到另一边,避免边缘出现半张卡突然消失。
周期宽度是 card + gap,不是卡片宽度本身。初始化和 resize 时测量 DOM 并缓存尺寸;动画帧里避免反复调用 getBoundingClientRect(),否则读取布局和写样式会互相触发。
模块 5:Wrap:无限坐标与有限 DOM
对应原文:
## Wrap — infinite in both axes
```js
const wrap = (min, max, v) => {
const r = max - min
return min + (((v - min) % r + r) % r)
}
// per cell: max = grid box + one trailing gap (= cols × (card + gap))
// margin = (max − viewport) / 2 → spare coverage split evenly
const tx = wrap(cell.right + margin.x - max.x, cell.right + margin.x, x.current)
Only cards in view get written. Off-screen cards receive one parking write, then are skipped until they wrap back in. Pad the visibility test by one period: at drag speed a card crosses the rim between two frames, and with no slack its first write lands already inside the viewport — a pop instead of a slide.
**讲解:**
```js
const wrap = (min, max, v) => {
const r = max - min
return min + (((v - min) % r + r) % r)
}
JavaScript 的负数取余可能仍是负数,所以要做两次 modulo 修正。用户可以一直拖动,target 坐标无限增长,但实际卡片始终落在有限周期内。
这也是它与无限加载列表的区别:数据和 DOM 都不需要持续增加,只复用固定节点。
模块 6:Warp:Dome
对应原文:
## The warp — dome + bulge on four points
```js
const warp = (px, py) => {
const nx = px / halfW, ny = py / halfH
const f = 1 + BOW * (nx * nx + ny * ny) // pincushion: rim spreads
let wx = px * f, wy = py * f
const dx = wx - cursor.x, dy = wy - cursor.y // gaussian shove away
const push = BULGE * bulgeWeight * Math.exp(-(dx * dx + dy * dy) / reach2)
return { x: wx + dx * push, y: wy + dy * push }
}
Warp the four corners of the card’s PERIOD box (card + half gap all round).
Neighbouring cards share those lattice points exactly, so the bow is
seamless. Then inset each corner by gap / 2 along its own two edges —
both neighbours inset from the same shared line, so the gap stays exactly
constant at any warp strength.
**讲解:**
圆顶不是 3D 曲面,而是对每张周期盒子的四个角应用 pincushion 函数。距离中心越远,放大因子越大,边缘因此向外展开;连续卡片拼在一起后,视觉上形成弯曲画廊。
实现重点不是修改卡片中心,而是修改四角。如果只对整个卡片做 `scale` 或 `skew`,得到的只是平面变换,无法形成原效果的梯形和不规则四边形。
## 模块 7:Cursor Bulge
**对应原文:**
```md
const dx = wx - cursor.x, dy = wy - cursor.y // gaussian shove away
const push = BULGE * bulgeWeight * Math.exp(-(dx * dx + dy * dy) / reach2)
return { x: wx + dx * push, y: wy + dy * push }
讲解:
鼠标鼓包是 Warp 的附加项:计算角点与光标的距离,用 Gaussian 衰减控制推力。靠近光标的区域被推开,远处迅速恢复。
它没有引入第二套渲染系统。bulge 只改变四个目标角,Inset 和 matrix3d 求解完全复用。原文还用拖动速度压低 bulge,让快速移动时画面更清晰、停下后镜头效果再回来。
模块 8:Rect → Quad Homography
对应原文:
## Rect → quad: the matrix3d
A homography maps a rectangle onto ANY straight-edged quad — keystone, skew
and scale all fall out of one solve. Unit-square solution, pre-scaled by the
card size, translation carried in the matrix (transform-origin: top left):
```js
const matrix = (w, q0, q1, q2, q3) => { // tl, tr, bl, br in local px
const dx1 = q1.x - q3.x, dy1 = q1.y - q3.y
const dx2 = q2.x - q3.x, dy2 = q2.y - q3.y
const sx = q0.x - q1.x - q2.x + q3.x
const sy = q0.y - q1.y - q2.y + q3.y
const den = dx1 * dy2 - dx2 * dy1
const g = den ? (sx * dy2 - dx2 * sy) / den : 0
const h = den ? (dx1 * sy - sx * dy1) / den : 0
const a = q1.x - q0.x + g * q1.x, b = q2.x - q0.x + h * q2.x
const d = q1.y - q0.y + g * q1.y, e = q2.y - q0.y + h * q2.y
return `matrix3d(${a / w}, ${d / w}, 0, ${g / w}, ${b / w}, ${e / w}, 0, ${h / w}, 0, 0, 1, 0, ${q0.x}, ${q0.y}, 0, 1)`
}
No perspective on any container, no transform-style — the matrix
carries its own projection and cards stay flat in z.
**讲解:**
浏览器不能直接接受“四个目标角”,所以 Skill 用 homography 求出从原矩形到目标四边形的投影矩阵:
- 平移来自目标左上角
- 缩放来自边长
- 倾斜来自边方向
- 梯形透视来自投影项
最终每张卡只有一个 CSS `matrix3d(...)`。这就是上一版实现效果差的关键原因:仅写一个简单 scale/skew 矩阵,并不等价于 rect-to-quad。
## 模块 9:Gap Inset
**对应原文:**
```md
Warp the four corners of the card's PERIOD box (card + half gap all round).
Neighbouring cards share those lattice points exactly, so the bow is
seamless. Then inset each corner by `gap / 2` along its own two edges —
both neighbours inset from the same shared line, so the gap stays exactly
constant at any warp strength.
讲解:
先对包含 gap 的周期盒子做 Warp,再让每个角沿相邻两条边向内收 gap / 2。相邻卡片共享同一个网格边界,各退半步后就能保持稳定沟槽。
如果每张卡独立扭曲而不共享周期边界,卡片之间的间距会随着位置变化,画面看起来就是互相倾斜的图片,而不是一个连续网格。
模块 10:Motion 与输入
对应原文:
## Motion
One self-owned rAF. Frame-rate-independent damping, dt clamped for the
tab-switch frame; a still-check skips all writes when nothing moves:
```js
const damp = (from, to, rate, dt) =>
from + (to - from) * (1 - Math.pow(1 - rate, dt * 60))
x.current = damp(x.current, x.target, 0.1, dt)
Inputs: wheel (+= deltas), pointer drag (target = origin − travel × speed),
keyboard (arrows nudge one cell = card + gap, space pans ~80% of a viewport,
shift+space back, Escape closes the flip), cursor damped separately for the
bulge. Suppress the bulge by velocity — calm = 1 / (1 + |lag| / 40) — so
the lens fades while the grid travels.
Lock the document so mobile browser chrome never reacts: overflow: hidden
and overscroll-behavior: none on html and body, position: fixed; inset: 0
on body (old iOS ignores overflow on body for touch), touch-action: none
on the stage, and a no-zoom viewport meta.
**讲解:**
Skill 把 `target` 和 `current` 分开:wheel、pointer drag、keyboard 只修改 target;一个自有的 `requestAnimationFrame` 用与帧率无关的 damping 追踪 current。这样拖动、滚轮、方向键和 FLIP 动画可以共用同一个运动循环。
移动端还要锁住 document 的滚动和缩放,避免浏览器地址栏或页面 overscroll 干扰全屏画布。
## 模块 11:Flip / Card Zoom
**对应原文:**
```md
## The flip (card zoom)
Not a separate animation system: flip progress (damped 0→1) lerps the
clicked card's quad corners from their live grid positions toward a centred
square, through the same inset + homography. The in-betweens un-keystone
naturally; the return leg lands on the moving grid slot for free. Side
panel and caption fades are remaps of the same progress value.
Layout details that matter:
- The white side panel is a CHILD of every card, parked exactly under the
image, slid out by a `--slide` var the flip writes (one cell, one var —
cheap). As card DOM it inherits the matrix, so it warps and travels along.
- The image↔panel gap is NEGATIVE by twice the border radius, in CARD-LOCAL
units (the matrix scales the radius up with the card), so the panel's own
rounded corners hide behind the image and its edge reads straight.
- Portrait (`wh > ww`): pair vertically instead — the panel slides out
underneath and the image takes most of the width. The unwarped text box
over the panel gets an absolute top inset equal to the overlap, so
scrolled text clips at the image edge instead of riding under it.
- While the card sits above the dim overlay (z lift), give it a per-card
dim stand-in computed from the same falloff at its position — set BEFORE
the z lift — so both hand-offs are invisible.
- Hold a full-viewport cover (plain div, class-driven opacity fade) until
every image has settled, with a timeout cap so a slow network can't hang
the reveal.
讲解:
点击卡片后,不复制一张新图,也不切换到完全不同的 modal。原卡片的四角从当前网格位置插值到屏幕中央的正方形,过程中持续走 Inset 和 homography,所以它会自然地去透视、放大和回到原位。
原站的说明面板是卡片子元素,因此继承同一套矩阵;移动端竖屏时,面板改为垂直排列。
模块 12:Flashlight
对应原文:
## The flashlight
A solid dim layer with the hole punched by `mask-image`, positioned by two
CSS vars written per frame (whole pixels, change-gated). Sample the stops
from a smoothstep — a two-stop gradient ramps alpha linearly and the equal
8-bit steps read as rings:
```js
const smooth = t => t * t * (3 - 2 * t)
// 12 stops: rgba(0,0,0,smooth(i/12)) at hole + (i/12) * (edge − hole)
Emit the stop positions in rem (value ÷ root font size) so they follow a
fluid type scale between resizes. On touch there is no cursor to chase, so
rest the hole at the centre and double its radius — it reads as a soft
vignette. Keep the same smooth() feeding any per-card dim math so
hand-offs match the overlay exactly.
**讲解:**
闪光灯是整屏暗层加 `mask-image: radial-gradient(...)` 的透明洞,不是逐卡修改亮度。光标移动时只更新光圈位置;触屏没有持续光标,则把光圈放在中心并扩大为 vignette。
原文特别提醒使用 smoothstep 和多个 gradient stops,避免两段渐变造成明显的色带。
## 模块 13:Performance
**对应原文:**
```md
## Performance rules
- Write `element.style.transform` directly. NEVER per-frame CSS variables
on cells: a custom property invalidates style for the whole subtree each
frame — across a grid of cards it shows as visible image flicker.
- Only in-view cards get writes; parked cards cost nothing.
- Per-card image parallax = direct transform on the `<img>` with a scale
bleed (`scale(1.1)`, bleed ≥ max drift).
- Opacity and transform only — nothing that lays out or paints per frame.
- Safari: a 3D-transformed `<img>` can be sorted above later absolutely
positioned siblings and swallow captions — give the overlays an explicit
`z-index` and their own `translateZ(0)` layer.
- Guard `resize()` with a generation counter: rotates fire several resizes
that interleave at the re-measure await, and a stale run finishing last
overwrites fresh geometry.
讲解:
性能规则可以归结为:
- 只在 resize 时测量布局。
- 只给视口附近的卡片写 transform,离屏卡片停车后跳过。
- 静止时停止 rAF。
- 不把每帧坐标放进 React/Vue 状态。
- 图片使用带 bleed 的缩放,避免 parallax 后露出边缘。
“120fps”是作者在特定设备和内容规模下的目标,不是所有设备的保证。
模块 14:Tunables 与 LUNA 实现边界
对应原文:
## Tunables
| knob | feel |
| --- | --- |
| `BOW 0.15` | pincushion strength (rim spreads 1 + BOW) |
| `BULGE 0.25 / REACH 1.5` | lens strength / radius in periods |
| `FLIP 0.7` | flipped card, fraction of viewport short side |
| damp rates `0.1–0.14` | scroll glide / flip speed |
| `SHADE 0.7 / TORCH 0.75` | dim strength / hole radius in periods |
讲解:
BOW 控制边缘扩张,BULGE / REACH 控制光标鼓包,FLIP 控制聚焦尺寸,damp rate 控制滑行感,SHADE / TORCH 控制遮罩和光圈。调参应保持流水线不变,只改变这些旋钮。
LUNA 当前 /explore 已使用固定 DOM 池、双轴 wrap、四角 dome warp、cursor bulge、gap 周期和 rect-to-quad matrix3d。它把 LUNA 内容作为卡片数据,但没有声称是原站的逐像素复制;原始 Skill 才是完整实现的参照。
结论
这个 Skill 真正提供的不是一个视觉模板,而是一套可组合的几何架构:有限 DOM 负责内容,wrap 负责无限坐标,warp 负责效果,homography 负责渲染出口。