那些厚厚的「3D」玻璃珠画片——就是当水晶摆件卖、人脸或 logo 仿佛浮在一片闪烁表面后面的那种——并不是印刷小把戏。前面是一片真正的 玻璃珠透镜(glass bead lens) 阵列,后面是一张印刷好的图像,而你透过它看到的东西从来都不是那张图本身:它是一个被放大、被重复的 摩尔纹放大(moiré magnification) 图案,会随你的头一起滑动,而这种运动就是立体感的来源。我不想再相信营销话术,于是写了 DotLens3D:一个 Python 桌面程序,用 GPU 着色器 做 光线追迹(ray tracing),每个像素追一条主光线,把这一整套结构正向仿真出来。这篇文章讲的是:这套光学到底是什么,为什么从眼睛反向追线与印刷品正向发出的光完全等价,以及我是怎么在一颗集成显卡 Intel UHD 620 上把它跑到可交互帧率的。
DotLens3D 是一个单窗口工具:一个可以拖动旋转的轨道相机、一个以毫米为单位的物理参数面板,以及一个通过让光线与珠子求交来决定每个像素颜色的片元着色器。
玻璃珠透镜阵列的几何
场景在毫米坐标系里是三个平行的平面。珠子阵列位于 z = 0,观察者在 +z 侧,印刷好的图像平面在珠子后方的 z = −d 处。默认场景用 2 mm 玻璃珠,排成 48×48 的正方网格,珠间边缘间隙 1 mm,像距 2 mm,折射率 1.5——就是普通钠钙玻璃。只支持正方排布;节距(珠心间距)等于 珠径 + 间隙,所以 2 mm 珠配 1 mm 间隙落在 3 mm 的节距上。这一个选择就决定了空隙率:珠子相切时大约留下 21.5% 的缝隙面积,而默认布局会露出大约 65%。没打到珠子的光不折射,直穿到印刷品上。
每颗珠子都是一枚球透镜,它从球心量起的焦距是 f = nR/(2(n−1))。当 R = 1 mm、n = 1.5 时,f = 1.5 mm。图像放在阵列后方 2 mm 处,于是透镜工作在略超出其焦平面的区间——正是这个区间产生放大效果,而不是一幅干净倒立的像。
薄透镜还是斯涅尔定律:对同一颗珠子的两种建模
模式 A,理想透镜,把每颗珠子当成位于 z = 0、孔径半径 R 的旁轴薄透镜。光线在珠子上的横向高度 h 处被偏折 α' = α − h/f,然后继续走到图像平面。没有球差,没有全反射,计算便宜,足以建立几何直觉。
模式 B,逐面折射,做的是老实的事:用解二次方程 t² + 2(u·(O−C))t + |O−C|² − R² = 0 取近根的方式求与前表面的交点,在每个碰到的表面上用 斯涅尔定律(Snell’s law) n₁ sin θ₁ = n₂ sin θ₂ 折射;半球从 z = 0 的平坦背面出射,整球则折射两次。当光线以过陡的角度打到玻璃时,全反射把它反射回去,着色器再尝试下一个出射面,直到一个固定的弹射上限(默认四次)。
模式 A 是模式 B 的旁轴极限。在渲染里,两者布局相同,但纹理不同:真实折射会把中心涡拧得更紧,在珠子边缘因全反射丢光而变暗,也会改变明暗带落下的位置。
为什么你看到的是摩尔纹放大,而不是那张照片
在针孔相机下,一颗珠子看不到整张图像平面,它只取样一个大约
span = 2R · |1 − d/f|
宽的窗口,并把窗口放大
|M| = f / (d − f)
倍。代入默认数值,span ≈ 0.667 mm,|M| = 3,这就解释了每一张「看起来不对」的渲染图。给阵列喂一张平滑照片,每颗珠子只会显示一块几乎纯色的色斑,因为 0.667 mm 尺度上平滑照片没有细节——结果就是一片彩色马赛克色块,而这是对的,不是 bug。给它喂一张自身周期接近珠子节距的图案,两种周期性互相拍频,眼睛看到的就是一个比珠子或圆环都大得多的低频 摩尔纹 图案。这与光栅透镜和集成成像印刷背后的采样原理是同一个,也是为什么每一件好的玻璃珠作品都从设计好的图案出发。
把像距推近焦距(这里取 d = 1.6 mm),|M| 会一路涨向 15,窗口缩小,圆环就融化成一个密得多的菱形摩尔纹。把珠子缩小到 1 mm、排成 96×96,节距本身变了,拍频随之改变,图案重组成另一个样子。
每个像素追一条光线的 GPU 着色器
渲染器是一个画在全屏三角形上的片元着色器——三个顶点由 gl_VertexID 生成,连顶点缓冲都不需要。对每个像素,它从相机基向量和视场角构造一条主光线,求出它与 z = 0 的交点,再用一次 floor 在阵列坐标里定位它落在哪颗珠子上。因为排布是正方的,所在格的格心就是最近的珠心——既不用邻域搜索,也不用加速结构。
然后它做了那件反直觉的事:它从相机反向追进场景,而不是从印刷品正向发射光线。在这里这是精确的,不是近似——依据是 Helmholtz 互易性:在一个无吸收、无多重反射的系统里,「相机到图像」和「图像到相机」给出同一幅画面。收益巨大:不用从每个图像像素向整个半球辐射、再等着看哪些光线打到透镜,我只追那些真正进入相机的光线。
CPU 侧刻意与着色器互为镜像:optics.py 是同一套公式的纯函数版本,lattice.py 构建完全相同的网格,单元测试断言两者一致。把同一份数值真相放在两处实现里,正是我在《如何读一份不是你写的代码库》 里写过的同一套纪律。
让它真正实时起来的工程
一个正确的着色器还不是一个能用的程序。
Qt 加 moderngl 的上下文陷阱。 最直觉的设计——用一个 QOpenGLWidget 配合绑定到它的 moderngl 上下文来绘制——在 X11 上会崩。第一次调用 moderngl.create_context(standalone=False) 会绑定当前 Qt 上下文并把它缓存起来;第二次调用发现缓存非空,走 mode="detect",于是新建了第二个上下文。两个上下文争抢同一个 GLX drawable,就是一次 BadAccess 加段错误。解决办法是干脆完全不碰 Qt 的 GL 上下文:建一个 standalone 上下文,渲染到离屏 framebuffer,把像素回读成 NumPy 数组,再交给一个普通 QWidget 当 QImage 显示。多个视图共享这唯一的上下文,在 Wayland 和 xcb 上都一样。
缓存阵列。 早期场景对象每帧都会重建珠心数组;对 128×128 的阵列,这要花大约 19 ms 一帧。把它缓存起来、只在 (列, 行, 珠径, 间距) 改变时重建,降到 0.012 ms,正向渲染从 35 fps 提到 125 fps。
只在画面变化时渲染。 一个脏标记让空闲的窗口零 GPU 占用,而不是空转。你拖动时超采样降到 1×;松手后回补到设定倍率。关键在于超采样发生在 GPU 上——按大尺寸渲染,第二个 pass 再缩放下来,只回读目标分辨率,所以昂贵的 CPU 回读量保持恒定。在参考机器上,1345×920 拖拽约 59.5 fps、空闲 2× 时 44 fps。1280×800 的裸吞吐高得多:理想模式约 1011 fps,半球折射 615 fps,整球 603 fps。
逆向设计:把 3D 模型打印到珠子背后
正向仿真告诉你一张给定的印刷品看起来怎样;更有用的问题是反过来——我该印什么,才能让一个 3D 模型显现出来?一个漂亮的小恒等式回答了它。让取样窗口等于节距的 N 分之一,span = 节距 / N,再代入 span 公式:
d = f · (1 + 1/N) 且 |M| = N
在这个像距上,每颗珠子恰好显示画布的一个子格,放大 N 倍。所以从 N×N 个略微不同的方向渲染模型,把这 N×N 张图按每 N 个像素取一个的方式交错成一张画布,放到距离 d 处,每颗珠子就挑出与当前观察方向相符的那个子格。你的头一动,每颗珠子就翻到相邻子格,整片阵列一起切换视角——这就是被人读成纵深的视差。实测 N = 15 观感最好;3、5、7 明显更跳,因为离散视角之间离得远。诚实面对的限制是空间分辨率等于珠子数量——每颗珠子一个采样——所以演示用 0.8 mm 珠、128×128 阵列,而 48×48 会明显偏糙。
它没有建模的东西
比起让一张漂亮的渲染图暗示出超出它能证明的东西,我更愿意把缺口讲清楚。
- 单折射率,无色散。 RGB 三个通道共用同一个 n,所以没有色差,高光里也没有彩虹——而真实的玻璃珠两样都有。
- 针孔相机,没有景深。 每条光线都从一个点进入,于是不存在离焦平面。
- 缝隙彩纹是真实的,不是 bug。 光从珠子之间穿过时,会打到画布边缘那些极端角度的子格,产生细细的彩色条纹;一块透明间隙在物理上就是这么做的。想要干净就选不透光的黑色基板,但那是另一个物理对象了。
- 离散视角的重影。 N 个视角之间有 N−1 个过渡,正处于过渡中的珠子会把相邻视角混成一幅淡淡的重影——真实的光栅印刷也有同样的瑕疵。
我从中带走的东西
DotLens3D 让我满意的地方在于:这套物理小到能装进脑子里,却依然出人意料。一枚球透镜、一条焦距公式、一个取样窗口,加上两张周期网格之间的拍频,就造出一幅看起来一定用到了更聪明东西的画面。其实并没有——它只是 Helmholtz 互易性、一次 floor,以及每个像素跑一次的着色器。
我也按自己现在喜欢的方式把它造了出来:本地、离线、属于我。它不需要服务器,生成的画布就是你的文件——也是我为本地优先软件 说过的同一套理由。如果你有一小堆珠子加一台打印机,逆向设计的路线现在能让你把模型放到它们背后;剩下的,不过是三个平面的几何和一条摆对位置的光线。