设计档案馆
中EN

把一张平面贴图放倒在三维地图上

设计工具的画布只有平面旋转,做不出「躺在倾斜地面上还在自转的光圈」,参数也交不给前端。我做了一个浏览器里的三轴变换台,导出的 CSS 渲染出来,四角和工具里的计算差不到 0.007 像素。

L.11 / 14
CATEGORY
工具
YEAR
2026
STATUS
笔记
TOOLS
webgl / css-3d / gif

entry记录

数据可视化的视觉设计里有一类需求反复出现:画面中央放一张手工调好透视的三维地图(渲染出来的位图),上面叠放光圈、扫描环这类元素,它们要倾斜到躺在地图平面上,同时绕自己的中心持续旋转。设计阶段卡在两件事上。一是常用的界面设计工具做不到:二维画布里的旋转只有平面内的一个角度,没有绕 X 轴往后倒的倾斜;工具新出的动画功能经查证只支持平移、平面旋转和缩放,3D 变换直接报不支持;社区插件能做透视变形,但多数会把元素转成位图,无法再改,也给不出参数。二是就算做出效果图,参数也交不出去,前端拿到一张图,倾斜多少度、透视多强、转一圈多久,只能目测再试。之前做过一个处理矢量图的三轴变换小工具,这次以它为原型,做一个面向位图和 GIF 的版本。

起初以为只是把图片转个角度,做到一半才理清,有三个要求必须同时成立:一是对位,正视图元素要倾斜到和底图的透视完全一致;二是预览,倾斜之后还要绕自身法线自转,看到动起来的样子;三是交付,角度、透视强度和动画参数交给前端后,前端照着写出来的效果要和工具里看到的一模一样。第三件后来最看重,因为设计侧调得再准,到了前端失真,前面的工作就白做了。所以验收导出的 CSS 时,要把它粘进页面实际跑一遍去量,不能只看代码写得像不像。

单图版的需求是一次导入一张普通图片或自动循环的 GIF,调节项参照旧工具,背景色固定。动手前定了三件事:导出 PNG、GIF 和视频三种格式;保留代码面板并改为输出 CSS;加上背景、辅助线和 GIF 播放控制。实现上用 WebGL(浏览器里的底层三维图形接口)把图片画成一个四边形。旋转顺序定为 Z、Y、X,恰好和 CSS 里 rotateX() rotateY() rotateZ() 从左到右的写法等价,导出 CSS 时不用换算。透视用顶点的齐次坐标实现,即给每个顶点多存一个代表离相机多远的数,显卡填充像素时按这个数做近大远小的校正,倾斜后的纹理不会被扭歪。GIF 用浏览器原生的 ImageDecoder 逐帧解码;由于运行环境不允许加载外部脚本,编码器是手写的。

第一版做完后,静态图片也需要能按轴做动画,例如导入一个圆环,X 轴固定倾斜 15°,Z 轴持续旋转。为此把旋转拆成「基准角度」加「每轴动画叠加」两层,动画支持循环、往返、单次三种模式。

单图层界面:只导入一个圆环,X 轴 15°,Z 轴循环自转。

接下来要加多图层、图层列表和画面内拖拽,先只做评估、不写代码。第一版评估很重,列了图层 Z 高度、深度自动排序、图层独立动画等一堆特性。把真实意图理清楚:导入一张已有透视的地图做底图,再导入一个正视图元素,调它的 X 角去匹配底图,让它自转,最后把参数交给前端。这一句话让方案砍掉了一半。第二版方案区分了「参考层」和「工作层」两种图层类型;继续追问有没有更通用的做法,最终决定不区分类型,参考层只是三轴角度全为 0 的普通图层,三个角度都是 0 时投影就是恒等映射,底图像素原封不动。属性拆成两级:场景级全局唯一,管相机距离、投影模式、画布和背景,相机必须唯一,否则多个图层不在同一个空间里;图层级每层一份,管三轴角度、平面内位移、不透明度和轴动画。另外加了一个「场景俯仰角」,对着底图标定一次存进场景,之后每导入一个新元素,点一下「对齐场景俯仰角」即可。我的用法是一张底图上摆一批元素,标定一次、复用多次,省下的工作量最多。

统一图层模型:场景级属性全局唯一,底图只是三轴为 0 的普通图层。

多图层版本定了两个使用约束。第一,底图只是偶尔用,只导入一张图时,界面和行为必须和单图版完全一样:单图层状态下,图层列表、场景控件、对齐按钮全部不出现,只多一个不起眼的「+ 添加图层」;拿同一组输入逐项比对重构前后的输出,唯一差异是有意在导出的 CSS 里多加了一行说明尺寸的注释。第二,先后导入两张图时,工具不去猜哪张是底图。「底图」只是使用者的概念,工具真正要定的只有画布多大,所以给一个合理的默认值,显式可见、一键可改,不做智能识别。理由是可预测比聪明重要:对位工具里自动识别错一次,代价是调了半天的角度全部作废。

画面内拖拽对位需要解决坐标换算:鼠标点在倾斜的画面上,对应图片上的哪个点。一个平面在透视投影下的成像,可以用一个 3×3 的单应矩阵精确描述;先用图层四个角的屏幕位置解出图片上每个点映射到屏幕哪里,再把这个关系倒过来用,就能把屏幕坐标换回图片坐标。命中测试读取图片的透明度,点在圆环中间的空洞上会穿透,选中下面的底图。

为了验证导出的 CSS 对前端有多大参考价值,把代码原样粘进一个空白页面去跑,发现了三个问题:第一,容器高度塌成 0,代码没有输出任何尺寸,子元素全是绝对定位,图层挂在容器外面;第二,透视距离写死,同一个 rotateX(52deg),元素缩小而透视距离不变时梯形变弱,梯形比从 1.347 漂到 1.061,元素看起来浮在地面上,而前端照着填了角度,不会觉得是自己的问题;第三,preserve-3d 让浏览器按三维相交关系重新排序,倾斜的下层切穿上层,把遮挡顺序搞反,和工具里看到的不一致。针对这三点修改了导出格式:场景容器带一个缩放变量 --k,所有尺寸和相机距离都写成 calc(数值 * var(--k)),前端只改一个数,整套参数等比缩放,透视的梯形程度不变;去掉 preserve-3d,把透视直接写在容器上,实测两种写法的梯形比相同,透视效果不变,遮挡顺序回到 DOM 顺序,与工具一致;注释面向前端写,讲清 HTML 骨架、DOM 顺序即遮挡顺序,以及哪些写法不能改和原因。

工具导出的 CSS 原样放进空白页面,渲染结果与工具一致。
对照实验:容器加了 preserve-3d 时,倾斜的下层 A 切穿上层 B;导出写法的遮挡顺序与工具一致。
对照实验:--k 为 0.35 时,透视随 --k 缩放的一侧梯形比仍是 1.347,写死的一侧已经变成 1.109。

实际使用中先导入一张小图、再导入一张大图时,大图显示不全,界面上还有一个「画布基准」标签。追查发现,它指画布尺寸跟着哪张图算,是一个要用户去猜的中间概念;同时还查出相机距离被定义成「系数 × 画布高度」,改画布尺寸会悄悄改掉所有图层的透视,已经对好的角度随之失效,且没有任何提示。解决办法是删掉「画布基准」,换成两个能直接改的数字;大图导入时画布自动撑开;改画布尺寸时反向补偿相机系数,保持绝对相机距离不变。实测画布从 286×286 改到 1150×805 再改到 1800×1400,绝对相机距离始终是 572px。界面上出现需要问「这是什么意思」的概念,就当作该删的信号。

拖拽时,被抓住的点逐渐偏离光标,误差达到 14 个源像素。原因是自动缩放把图层位移也算了进去,位移变大画面就缩小,缩小后单应矩阵跟着变,形成了反馈回路。改为自动缩放只补偿旋转带来的外扩、不受用户设定的位移影响后,抓点误差降到 0.05 个源像素,全部来自 0.1px 的取整。

正交模式下导出的注释原本写着「正交投影 CSS 没有原生写法」。实测发现是注释写错了:CSS 里不设透视,本身就是正交投影;一个 400px 高的元素设置 rotateX(45deg),上下边等宽,高度为 282.8427px,正好等于 cos 45° × 400。于是把注释改对,并显式写出 perspective: none。这一行不改变行为,作用是表明意图:缺了这一行读起来像疏漏,前端可能会补一个数值,把正交变成透视。

导入的图片看上去像带了投影。排查发现,画布容器上有一条装饰性的 box-shadow,在画布下方 60px 内形成一圈暗晕,最暗处比背景低 20 个色阶。虽然导出文件不受影响,但在对位工具里,画布外的任何明暗变化都可能被误认成画面内容。改为只留一根 1px 的边界线。

修复前画布下方有一圈渐变暗晕,修复后只剩 1px 边界线。

另一个更隐蔽的问题是带半透明像素的图层显脏、发灰。用一块 alpha(不透明度)为 128 的纯色复现,正确合成结果应为 rgb(131, 94, 39),实际得到 rgb(71, 53, 27)。按「alpha 被乘了两次」反推,三个通道都对得上,例如红色通道 240 × 0.502² + 22 × 0.498 ≈ 71.5。根因在于浏览器生成的位图默认已经预乘过 alpha(即把颜色值预先乘上不透明度),纹理上传时却按「未预乘」声明,着色器里又乘了一遍;半透明信息用了两次,颜色多压暗一次。全透明和全不透明的地方不受影响,越接近半透明误差越大,看起来就是中间调发灰。办法是整条链路统一按预乘处理,上传时预乘,着色器不再重复乘。这顺带解决了另一个隐患:纹理插值在预乘空间里进行,旋转缩放时,透明像素里残留的颜色不会再混进边缘形成灰边。

误差曲线:修复前的实测点落在「alpha 被乘了两次」的理论曲线上,修复后全部贴近 0。
半透明素材修复前后的真实导出。

渲染修好后,导出的 GIF 仍残留明显偏差。原因是 GIF 只有 256 色,调色板按固定步长抽样,几十帧几乎相同的画面把抽样预算花在了重复像素上,面积很小的最亮部分一次都没被抽到。把策略改为「固定步长抽样」与「每种出现过的颜色至少留一个代表」两路合并后,辉光中心的偏差从 96 降到 5。采用前先在本地用两类典型素材做了对比:渐变素材的全图均方误差从 5.57 降到 1.38,多色素材从 26.70 降到 22.19,确认两类都变好后才换上。

修复前辉光中心被压成一整块,修复后层次恢复。

多图层重构完成后做了一次独立代码审查,审查方只看代码、不看开发过程,每一条问题都要求给出具体行号和可复现的操作。报告列出了 9 个确认缺陷和 6 个风险项,其中最要紧的有三个:一是导出 CSS 的动画时长错了,所有图层统一用了场景周期,1 秒转一圈的元素在 CSS 里变成 3 秒一圈,直接影响交给前端的参数;二是「单次」模式会污染其他图层,一层设成单次,所有层的 CSS 都只播一次;三是 GIF 导出把所有帧的像素缓存在内存里,大尺寸、高帧率时要占约 4GB 内存,有耗尽内存的风险,改成两遍处理后内存峰值只有一帧大小。这些问题全部修复,并补了回归测试。

整理这篇记录时才发现,本地离线版只有一张图时,「图层不透明度」和「对齐」按钮本该隐藏,却显示了出来。原因是在线版的运行环境自带一条让 hidden 属性生效的样式,之前的本地测试页也加了同样一条,两边一起把问题盖住了。随后让工具自己带上这条样式规则,不再依赖外部环境。此外,几次纠错过程中测试本身也出过错:一次把不透明的色块导入到了上层,把要测的半透明层整个盖住;另一次没关自动缩放,导致按半径取样的位置对不上。这些都是因为量出来的数和预期不符才被发现,因此每个结论都以实测数值为准。

CSS 输出面板:注释面向前端,标明标定值和不能改的写法。

最终交付的工具是一个 87KB 的单 HTML 文件,断网也能运行,图片只在本机内存里处理。导出的 CSS 在浏览器里渲染出的四角,与工具内部计算的偏差在 --k 为 1 时不超过 0.001px,为 0.35 时不超过 0.007px;半透明渲染误差从 112/255 降到了 1/255。以前那种平躺在倾斜地面上、还在自转的光圈只能由前端反复试角度,现在能在设计侧对着底图把角度调准,再把参数原样交出去。

process对比

验证结果(均为实测)

CSS 渲染偏差
--k = 1 时 ≤ 0.001px · --k = 0.35 时 ≤ 0.007px
正交模式偏差
≤ 0.0002px
拖拽抓点误差
0.05 源像素(修复前 14)
坐标往返误差
屏幕 ↔ 图片约 10⁻¹³px
三轴为 0 的输出
28000 个像素中 0 个有差异
半透明渲染误差
1/255(修复前 112)
GIF 渐变峰值偏差
5(修复前 96)
绝对相机距离
画布变化前后始终 572px

工具能力

导入
PNG · JPG · WebP · GIF
变换
三轴旋转 · 透视 / 正交投影 · 每轴单独设置动画
图层
多图层叠放 · 画面内拖拽对位 · 场景俯仰角
导出
PNG · GIF · 视频 · 单图层、多图层、正交三种格式的 CSS

导出的 CSS

css
.scene {
  --k: 1; /* 页面里按 20% 摆放就写 .2 */
  width: calc(832px * var(--k));
  height: calc(546px * var(--k));
  perspective: calc(1092px * var(--k)); /* 相机距离,必须跟着 --k 缩放 */
}
.layer-2 {
  /* 圆环 */
  transform: rotateX(52deg) rotateZ(0deg); /* 52° 是对照底图标定出来的 */
  animation: tilt3d-2 2.4s linear infinite;
}

result小结

结论
导出的 CSS 在浏览器里渲染出的图层四角,与工具内部计算的偏差在 --k = 1 时不超过 0.001px、--k = 0.35 时不超过 0.007px;半透明像素的渲染误差从 112/255 降到 1/255。
参数
旋转顺序 Z→Y→X · 尺寸与相机距离统一乘 --k · 不用 preserve-3d · 改画布尺寸时绝对相机距离不变
没做完
GIF 逐帧解码依赖浏览器的 ImageDecoder,目前 Chrome 和 Edge 可用;不支持时工具仍能预览 GIF,但不能逐帧定位,也不能导出 GIF 或视频。

log日志

2026-10
首次记录

相关实验

note
L.09(TOOL)2026

把 37 块设计稿搬进 Figma,逐像素证明没走样

toolingpythonnumpy+2
note
L.14(TOOL)2026

从一张 3D 倾斜地图到一个离线工具

toolingsvggeojson+1
note
L.01(STUDY)2026

(一)赛前选定基座模型

chest-xraypytorchrad-dino+1
← LabL.11 / 14
1 / 1

100%