·约 10 分钟阅读·DeepSeek

GPU 上的玻璃珠透镜光线追迹:摩尔纹放大与光栅视差

正向仿真玻璃珠 3D 图片背后的光学:用逐像素 GPU 着色器对玻璃珠阵列做光线追迹,薄透镜偏折与斯涅尔定律折射的对比,摩尔纹放大与视差为何出现,Qt 加 moderngl 的上下文陷阱,以及把 3D 模型打印到珠子背后的逆向设计。

pythonlinux

那些厚厚的「3D」玻璃珠画片——就是当水晶摆件卖、人脸或 logo 仿佛浮在一片闪烁表面后面的那种——并不是印刷小把戏。前面是一片真正的 玻璃珠透镜(glass bead lens) 阵列,后面是一张印刷好的图像,而你透过它看到的东西从来都不是那张图本身:它是一个被放大、被重复的 摩尔纹放大(moiré magnification) 图案,会随你的头一起滑动,而这种运动就是立体感的来源。我不想再相信营销话术,于是写了 DotLens3D:一个 Python 桌面程序,用 GPU 着色器 做 光线追迹(ray tracing),每个像素追一条主光线,把这一整套结构正向仿真出来。这篇文章讲的是:这套光学到底是什么,为什么从眼睛反向追线与印刷品正向发出的光完全等价,以及我是怎么在一颗集成显卡 Intel UHD 620 上把它跑到可交互帧率的。

DotLens3D 是一个单窗口工具:一个可以拖动旋转的轨道相机、一个以毫米为单位的物理参数面板,以及一个通过让光线与珠子求交来决定每个像素颜色的片元着色器。

一张同心圆环波带片图案透过 48×48 的 2 毫米玻璃珠阵列后,产生由菱形与棋盘形状构成的大面积摩尔纹。
默认场景(2 mm 玻璃珠、48×48 阵列、像距 2 mm、理想透镜模式)下的参考波带片图案(细密同心圆环)。你看到的不是那些圆环,而是珠子节距与圆环周期之间的拍频——大面积的摩尔纹放大。

玻璃珠透镜阵列的几何

场景在毫米坐标系里是三个平行的平面。珠子阵列位于 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,节距本身变了,拍频随之改变,图案重组成另一个样子。

96×96 的 1 毫米无间隙玻璃珠阵列观察波带片,产生一个星芒十字形的摩尔纹图案,而非之前的菱形棋盘。
改变珠径与节距,拍频也随之改变: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,以及每个像素跑一次的着色器。

我也按自己现在喜欢的方式把它造了出来:本地、离线、属于我。它不需要服务器,生成的画布就是你的文件——也是我为本地优先软件 说过的同一套理由。如果你有一小堆珠子加一台打印机,逆向设计的路线现在能让你把模型放到它们背后;剩下的,不过是三个平面的几何和一条摆对位置的光线。