MatrixEffect:把图像变成可编程的视觉矩阵
最初的想法很简单:把一张图抽象成一个低分辨率矩阵,再让每个格子的明暗,决定这个位置上圆点的大小。
如果亮处对应大圆点,暗处对应小圆点,一张普通图片就会变成有节奏的点阵。把圆点换成按视觉密度排序的字符,又会得到 ASCII 艺术。输入不再是一张静态图,而是缓慢移动的光团、旋转的星旋或外部 Canvas,结果便自然地动了起来。
真正开始实现后,我发现这里值得封装的不是“一个圆点特效”或“一个 ASCII 组件”,而是一套更底层的能力:
把不同来源的视觉信息采样成统一信号场,再让可替换的映射和 Renderer 决定它最终长什么样。
这就是 MatrixEffect 的起点。
一、先建立直觉:每个格子都只回答一个问题
假设画布被划分成 columns × rows 个格子。对每个格子,我们先从输入图像中获得一组信息:
r / g / b / a- 灰度或亮度
- 经过对比度、阈值、反相等处理后的主信号
value - 格子在画布中的位置与尺寸
Renderer 不需要知道原始输入是一张照片,还是一团程序化的光。它只需要回答:拿到这个格子的 value 后,我要画什么?
圆点 Renderer 可以这样理解:
ASCII Renderer 则把连续信号映射到离散字符集:
这两个结果看起来完全不同,但它们消费的是同一份信号场。
二、最终管线:Source 与视觉结果解耦
整个组件被拆成五个阶段:
这样的拆分有一个直接好处:输入和输出不再彼此绑定。
同一个柔和光团 Source,可以交给圆点 Renderer,也可以交给 ASCII Renderer;同一个 ASCII Renderer,又能消费静态图片、上传图片或旋转星旋。想做新的视觉效果时,很多时候只需要换最后一段绘制逻辑,而不用重新处理图片加载、响应式网格和动画生命周期。
这和 Shader 的思路很接近:采样输入、转换信号、输出图元。不过首版没有直接锁定 WebGL,原因也很现实:
- ASCII 需要浏览器字体测量和
fillText()。 - 使用者需要直接传入 JavaScript / TypeScript 函数。
- Canvas 2D 对这类中低密度图元已经足够,也有更低的接入成本。
因此它更准确的定位是:CPU 侧的采样场渲染引擎,而不是一个被包装起来的 GLSL Shader。
三、为什么要先画到低分辨率采样画布
如果直接对一张 4K 图片调用 getImageData(),再逐像素处理,绝大多数计算其实都被浪费了。最终画面可能只有几十列圆点,我们真正需要的只是几十列采样结果。
MatrixEffect 会先创建一个不挂载到 DOM 的内部 Canvas,它的分辨率就是当前网格的 columns × rows:
以 100 × 100 网格为例,RGBA 数据约为 40 KB。采样成本只与网格规模相关,不再跟原始图片分辨率线性增长。
这层内部 Canvas 也统一了三类 Source:
程序化 Source 的关键不是自己再开一条动画循环,而是只声明 draw()。组件核心会把有效播放时间传进来,并统一管理 rAF、暂停和恢复。
四、先用预设:一个可直接落地的圆点矩阵
组件通过 QiuYe UI registry 分发,安装后可以直接使用预设:
DotMatrixEffect 在未传 source 时,会自动创建确定性的柔和光团。下面这段代码已经包含动态输入、响应式网格、主题配色和性能边界:
这里几个参数分别控制不同层次:
blobOptions改变输入场本身。levels改变采样信号的明暗分布。radiusRange决定信号最终如何表现为圆点。grid决定视觉密度和性能上限。
把它们分开之后,调参不再是一组互相纠缠的魔法数字。
五、ASCII 不是“把圆点换成字符”这么简单
ASCII 的核心映射并不复杂。字符集从低视觉密度排到高视觉密度,例如:
低亮度选择空格、点号或冒号,高亮度逐渐过渡到 #、%、@。组件还支持传入 glyph 数组,因此一个映射单元不一定只能是单个 Unicode code point。
实际 Demo 还提供了旋转星旋、流动光团、呼吸圆环、流动波浪和上传图片。颜色既可以统一指定,也可以直接保留 Source 的 RGB。
一个容易忽略的比例陷阱
我在实现 ASCII 时踩到过一个很典型的坑:等宽字体的字面宽度通常小于行高,于是很容易顺手把单元格宽高比设成 0.6。
但 MatrixEffect 的网格不只是字符排版格,它同时也是 Source 的采样坐标系。如果采样格也使用 0.6,输入会先被采成非方形网格,再铺回画布,圆形与人物都会发生横向压缩。
正确做法是把两个概念分开:
- 采样 / 布局单元格保持 1:1,确保原始构图比例不变。
- glyph 保持字体自身比例,在方形单元格中居中绘制,不横向拉伸字符。
- 用
fontScale控制字面尺寸,而不是篡改采样坐标系。
这个问题很能说明通用组件为什么需要明确的数据边界:视觉上相似的“格子比例”和“字符比例”,在管线里承担的是完全不同的职责。
六、从预设走向自定义 Renderer
圆点与 ASCII 只是两个默认答案。底层的 MatrixEffect 可以接收自定义 Renderer;简单场景可以使用 createCellRenderer(),直接获得每个格子的瞬时视图。
下面把同一组柔和光团改画成方块:
cell 中除了 value,还包含 r / g / b / a、行列索引、归一化坐标 u / v 和 CSS 像素几何信息。于是 Renderer 可以控制的不只是尺寸,还可以是:
- 颜色、透明度与混合模式
- 旋转角、线宽、圆角与形状
- 字符、图标或路径选择
- 基于位置的波纹、偏移与扭曲
如果逐格回调的成本仍然太高,还可以直接实现整帧 MatrixRenderer,读取 TypedArray 并批量绘制。
七、响应式场景不应该强制 n × n
设计早期很容易把矩阵写成固定的 100 × 100。但页面容器可能是横屏、方形或竖屏,强制方阵会让单元格被拉伸,或在两侧留下不自然的密度差。
自动网格模式会根据容器比例计算 columns × rows:
同时受 maxCells 限制。容器变宽时主要增加列数,变高时主要增加行数,而不是把整个输入挤进一个固定方阵。
这带来三个结果:
- 圆点与字符的几何比例保持稳定。
- 同一组件可以自然进入卡片、Hero、侧栏或移动端。
- 性能预算可以直接通过单元格数量表达。
只有在需要确定性导出、像素对齐或复现实验结果时,才更适合显式指定固定 columns 与 rows。
八、动态效果真正难的是调度,而不是多写一个 rAF
让画面动起来只需要几行 requestAnimationFrame();让它在真实页面里长期稳定运行,则需要处理更多状态:
- 页面切到后台时暂停。
- 组件离开视口时暂停。
playing=false时停止连续循环。prefers-reduced-motion下绘制静止帧。- Resize 或配置变化时合并重绘请求。
- 恢复播放后时间连续,但不把暂停时长算进去。
- Source 或 Renderer 抛错时保留最近一次成功画面。
核心最终只维护一条 rAF 链,并使用 dirty 语义合并 invalidate()。暂停不是每帧进入回调后提前 return,而是彻底取消活动循环;否则不可见页面仍然在持续唤醒主线程。
自适应 30 / 60 FPS
圆点的默认目标是 60 FPS,ASCII 默认偏向 30 FPS。frameRate="auto" 会根据持续帧耗时在两个档位间调整,并带有降级窗口和升级冷却,避免在 30 / 60 之间来回振荡。
组件还同时限制:
这些配置不是为了保证每台设备都达到某个绝对帧率,而是让组件在压力下有明确的退路。
九、热路径里,有些规则必须足够无聊
视觉组件很容易把注意力都放在效果上,但稳定性通常来自一些朴素约束:
- 不为每个格子创建 DOM 或 React 节点。
- 不在每帧
setState()。 - 网格不变时复用
Float32Array。 - 不在 ASCII 的每个格子里调用
measureText()。 - 单色圆点尽量合并到同一个 Path 后统一
fill()。 - 不对高分辨率原图直接
getImageData()。 - Source、Renderer 和 Transform 视为不可变配置,使用模块常量或
useMemo保持身份稳定。
createCellRenderer() 内部也只复用一个 scratch cell,在遍历时覆盖字段,而不是每格创建一个新对象。它对调用方仍然很好用,但不会把便利性变成持续 GC 压力。
十、组件边界:首版刻意没有做什么
通用不等于把所有输入都塞进首版。当前实现支持静态图片、外部 Canvas 和程序化 Source,但没有内置:
- 视频、摄像头和屏幕共享
- 音频频谱与节拍响应
- DOM 截图
- 鼠标、触摸和压力输入
- WebGL / WebGPU Renderer
- GIF、视频或图片导出
这些能力都可以继续扩展,但它们需要新的权限、生命周期或后端契约。尤其是 GPU 后端,JavaScript Transform 不可能自动变成 GLSL / WGSL,未来必须设计独立插件接口,而不是假装两者可以无成本互换。
指针与触摸输入也更适合作为 Source 层扩展:把位置、速度或压力转成一个程序化场,再复用现有 Mapper 与 Renderer。这样交互不会侵入核心管线。
结语
MatrixEffect 最有价值的地方,不是它能画圆点,也不是它能生成 ASCII。
它把视觉效果拆成了几件可以独立思考的事:输入是什么、如何采样、信号怎样变化、最后画成什么,以及这套过程如何在页面生命周期里克制地运行。
当这条管线稳定后,新的效果不再意味着重写一整个组件。换一个 Source、插入一个 Transform,或写一个新的 Renderer,就能得到另一种视觉语言。
有时候,通用视觉组件并不是提供更多开关,而是找到那条足够稳定的数据流,让不同的光、字符和形状都可以安静地经过它。
你可以在 QiuYe UI 的 Matrix Effect 组件页 查看完整 Demo、API 与安装方式。