行业资讯
📅 2026/9/9 22:03:58
Python从零实现光线追踪:球体、阴影与镜面反射
简介这是一份基于MATLAB的光线追迹计算程序面向太阳能热发电、光伏系统与光学设计领域的工程师和研究者用于模拟光线在介质中的传播、反射与折射过程。压缩包体积仅6KB共7个文件以m脚本为主涵盖反射、折射、交点计算等核心模块同时附有README说明与开源许可文件结构简明便于二次开发。目前已有565人学习/下载。程序采用几何光学原理通过自定义光学参数如折射率、反射率构建三维光路模型并引入Chebyshev多项式近似路径变化以提高计算效率借助MATLAB图形功能可输出光线路径与强度分布辅助分析集热器聚焦效果、评估光伏面板受阴影和尘埃的影响。使用者可直接运行示例脚本调整参数并观察结果加深对光路追迹算法及光学系统性能的理解。 如果你这两天正在折腾“光线追迹”相关的程序多半和我一样最初是被一张反射、折射、阴影都极其真实的渲染图勾进来的。这个项目“光线追迹——相关程序-1”就是我动手写的一个最小可运行版本用 Python 从零实现球体、平面、点光源和镜面反射最后输出一张自己生成的图片。它不是能跟商业渲染器比画质的东西而是把光线追踪最核心的数学和流程跑通适合想彻底搞懂原理、而不是只会调库的开发者。我在下面把程序的设计思路、核心公式、完整落地步骤和踩坑过程都整理出来希望能帮你少走弯路。我默认你有最基本的 Python 基础知道怎么打开终端、怎么运行脚本。不需要图形学基础所有公式我都会拆开解释。整个项目只用到标准库不依赖 numpy、opencv 这些重型依赖所以哪怕你机器上环境比较干净也能直接跑起来看效果。1. 项目定位与整体思路1.1 为什么要手工写光线追迹而不是用现成渲染器市面上能出图的开源方案很多Blender Cycles 用路径追踪Unity、Unreal 的实时渲染也自带光追甚至用一个 Python 库就能直接渲染出照片级画面。但这类工具把底层细节全部封装掉了你只会调参数不知道“颜色是怎么算出来的”。我写这个程序的目标很单纯用最少的代码把光线追踪的五个核心环节跑通包括射线生成、交点求解、阴影判断、光照计算、递归反射。当你能亲手修改“金属球的反射次数”并观察到画质变化理解深度完全不一样。这个程序选择用纯 Python是因为 Python 的列表、元组、数学函数足够表达整个算法而且调试成本低。性能确实比不上 C但作为教学和验证用已经完全够了。如果你后面想追求性能可以在理解原理后换成 C 或 CUDA那时候你会发现自己写的代码和书上伪代码几乎一一对应迁移成本很低。1.2 技术选型对象模型、图像格式和光照模型场景对象方面我选择了球体和无限平面。球体的求交有解析解只需要解一元二次方程代码只有十行无限平面 y-1 用来模拟地面它和射线的求交只需一次除法。三角形网格是更通用的选择但三角形的求交和重心坐标计算会让第一版程序膨胀很多不适合作为入门第一课。光照模型上我用了 Blinn-Phong 的简化版本环境光加漫反射加高光再加一层镜面反射递归。真正的路径追踪需要蒙特卡洛采样和 BRDF会引入大量随机数和概率公式容易让新手陷进去。Blinn-Phong 足够展示“光线追迹”最惊艳的镜面反射效果而且每一步计算都有明确的几何意义。图像输出我选了 PPM 格式。这个格式只用一个文本头加 RGB 像素数据就能保存不需要任何第三方的解码库写文件直接用 Python 内置的 open 就能完成。Windows 自带的图片查看器不一定支持 PPM所以我会额外给出一行转换命令方便在本地预览成 PNG。2. 核心原理与代码骨架2.1 光线模型从像素出发而不是从光源出发真实世界里光源发出光子打到物体上再进入人眼。但如果程序模拟每一条光子路径绝大多数光子都不会进入相机计算量巨大。光线追迹的核心思路是反着来从相机眼睛出发穿过屏幕上每个像素发射一条射线让射线去场景里“问”物体你挡住我了吗你是什么颜色你的表面法线朝哪代码里射线用一个原点origin和一个方向direction表示。屏幕上某个像素对应一条射线射线的参数方程是P(t) origin t * direction其中 t 是大于 0 的实数表示沿着射线方向走了多远。求交就是找最小的正 t让 P(t) 刚好落在某个物体表面上。像素坐标到射线方向的转换需要注意宽高比。直接用一个正方形视口会拉变形所以要乘以宽高比修正。我给相机定义了一个简单模型相机在原点看向 z 轴负方向视口是一个宽 2.0、高 2.0 的矩形放在 z -1 处。屏幕上第 i、j 个像素对应的视口坐标是worldX (2 * (j 0.5) / W - 1) * aspectworldY 1 - (2 * (i 0.5) / H)很多新手在这里踩坑忘了加0.5导致取到的是像素左上角或者忘了处理 Y 轴反转导致渲染出来的图上下颠倒。这些我都会在后面的排查章节里展开。2.2 球体求交一元二次方程的直观意义球体求交是光线追踪里最优雅的一部分。球心 c、半径 r 的球面方程为|P - c|^2 r^2把射线方程代入展开后得到关于 t 的一元二次方程(D·D)t^2 2D·(O-c)t (O-c)·(O-c) - r^2 0其中 O 是射线原点D 是方向。判别式 b^2 - 4ac 如果小于零说明射线没打中球等于零是切点大于零有两个解取较小的正 t 就是最近的交点。我见过不少初学者在这里直接把公式原样抄到代码里结果算出来的交点位置总是有点偏。原因通常是方向向量没有归一化。如果 D 的长度不是 1二次方程里的常数项和一次项系数就会被缩放直接代入求根公式的结果虽然 t 值倍率不同但计算交点时如果忘了在同一坐标系里使用就会出现不一致。所以我强烈建议射线方向在生成时就归一化这样后续很多计算都会被简化。2.3 光照与阴影点光源、法线和遮挡判断有了交点和法线就可以计算颜色了。一个标准的 Blinn-Phong 光照分为三部分环境光模拟全局间接光照的近似给物体一个基础亮度防止阴影部分全黑。通常是ambientColor * objectColor。漫反射光线被粗糙表面各方向散射。亮度与max(dot(normal, lightDir), 0)成正比这也是兰伯特余弦定律。当光线垂直于表面时最亮擦边时最暗。高光反射模拟光滑表面上的亮斑。Blinn-Phong 用半程向量H normalize(lightDir viewDir)与法线点乘再取 n 次幂。n 越大高光越锐利材质越像塑料或金属。阴影的判断方式是做一条“阴影射线”从交点朝着光源方向发射看场景中是否有物体挡在中间。只要找到一个 t 大于很小偏移量防止自相交且小于光源距离的交点就说明该点处于阴影中此时漫反射和高光部分都不生效。这里特别要注意“自相交”问题。阴影射线如果不加偏移量会直接和当前物体本身再次相交导致画面出现密密麻麻的黑色噪点或“痘痘感”。解决办法是在交点位置沿法线方向偏移一个微小距离再发射阴影射线。2.4 递归反射镜子怎么看到镜子镜面反射是光线追迹最容易出效果的部分。当一条射线打到一个反射物体表面时根据反射定律生成一条新射线继续在场景中递归追踪。反射方向用公式reflectionDir incidentDir - 2 * dot(incidentDir, normal) * normal注意这里的入射方向是从表面指向相机的反方向也就是-ray.direction否则反射方向会算反画面会一团乱。递归需要一个最大深度限制。我默认设置 4 层过深的递归对画质提升有限却会成倍拉长渲染时间。每层递归时反射颜色乘上反射系数比如 0.8这样反射越多次能量衰减越明显避免画面过曝。3. 从零到一环境搭建与完整实现流程3.1 环境准备与目录结构我用的是一个很普通的 Python 3.9 环境完全不需要虚拟环境也能跑。建议新建一个独立目录比如raytracer_01在里面放一个rt.py文件再把输出图片保存到out.ppm。这样一个文件搞定所有逻辑方便随时用 Python 单文件方式执行。如果你在 Windows 的 PowerShell 或 CMD 里输入python时系统提示“python 不是内部或外部命令”或者提示“无法将 python 项识别为 cmdlet、函数、脚本文件或可运行程序的名称”这通常是 Python 没有加入 PATH 环境变量。我建议安装 Python 时勾选“Add Python to PATH”已经装好的也可以到系统环境变量里手动补充路径。这类“命令找不到”的问题几乎每天都要见一次本质上就是可执行文件目录没被系统搜索到和程序代码本身无关。Git、pip、pnpm、claude 这类工具提示“不是内部或外部命令”“无法将 xx 项识别为 cmdlet”时原因和处理方式完全相同检查对应工具的安装目录把它加到 PATH 里再重开一个终端窗口。macOS 或 Linux 上大概率自带 Python 3直接在终端里运行即可。唯一可能遇到的问题是权限不足这时用python3而不是python或者用虚拟环境隔离依赖。3.2 核心代码向量、球体与主循环我把最关键的一部分代码贴出来标注了核心步骤。import math class Vec3: def __init__(self, x0, y0, z0): self.x, self.y, self.z x, y, z def __add__(self, o): return Vec3(self.x o.x, self.y o.y, self.z o.z) def __sub__(self, o): return Vec3(self.x - o.x, self.y - o.y, self.z - o.z) def __mul__(self, k): return Vec3(self.x * k, self.y * k, self.z * k) def dot(self, o): return self.x * o.x self.y * o.y self.z * o.z def norm(self): length math.sqrt(self.dot(self)) return self * (1.0 / length) def intersect_sphere(ray_origin, ray_dir, center, radius): oc ray_origin - center a ray_dir.dot(ray_dir) b 2.0 * oc.dot(ray_dir) c oc.dot(oc) - radius * radius disc b * b - 4.0 * a * c if disc 0: return None t (-b - math.sqrt(disc)) / (2.0 * a) if t 0: t (-b math.sqrt(disc)) / (2.0 * a) return t if t 0 else None def intersect_plane(ray_origin, ray_dir, y_val): if abs(ray_dir.y) 1e-6: return None t (y_val - ray_origin.y) / ray_dir.y return t if t 0 else None这段代码里intersect_sphere返回最近的交点参数 tNone表示没有命中。intersect_plane用平面方程 y y_val 简化了求交只需要解origin.y t * dir.y y_val。主循环的结构是遍历图像每一行每一列生成像素对应的射线。用trace函数递归求最近交点计算颜色。写入 PPM 文件。trace函数伪代码如下def trace(ray_origin, ray_dir, depth): # 找到最近的交点先检查所有球体再检查平面 # 如果没有命中返回背景色天空渐变 # 如果命中计算法线、视角方向、光照方向 # 发射阴影射线判断是否在阴影中 # 计算漫反射、高光递归计算反射颜色 # 返回环境光 漫反射 高光 反射颜色背景色我用了从淡蓝色到白色的渐变根据射线方向的 y 分量插值。这样画面即使没有物体也不会死黑一片看起来更清爽。3.3 输出图像PPM 格式与 PNG 转换PPM 的格式很简单第一行P3第二行宽高第三行最大颜色值 255之后就是每行 RGB 数据。with open(out.ppm, w) as f: f.write(P3\n{} {}\n255\n.format(W, H)) for i in range(H): for j in range(W): r, g, b [min(255, max(0, int(c))) for c in (color.x, color.y, color.z)] f.write({} {} {}\n.format(r, g, b))要转换成 PNG 预览可以用 PowerShell 调用 .NET 的 System.DrawingWindows 专属或者更简单安装 Pillow 后运行from PIL import Image Image.open(out.ppm).save(out.png)其实不转换也不影响理解算法PPM 是最贴近“裸数据”的格式反过来更适合验证渲染器是否正确。3.4 参数怎么调分辨率、递归深度和光源位置我在渲染测试时先用 320×240 分辨率跑确认画面基本正常再改成 1280×960 出图。因为递归光线追迹复杂度是 O(像素数 × 物体数 × 递归深度)大分辨率下哪怕场景只有两个球也能明显感到计算时间变长。光源位置对阴影形状影响很大。我建议把点光源放在相机斜上方比如Vec3(1, 2, 1)这样球体投在地面上的影子是斜的视觉上更有空间感。光源离物体太近阴影边缘会显得很硬但会暴露算法本身的实时渲染风格这也是正常的。点光源只产生硬阴影如果想要半影效果需要扩展到面光源或做软阴影采样那是后面优化的方向。4. 踩坑实录常见问题与排查技巧我在写这个程序的过程中踩了不少坑也帮朋友排查过类似问题这里整理成一张速查表你在实操中可以直接对照。现象可能原因解决思路命令行提示“python 不是内部或外部命令/无法识别”Python 未加入系统 PATH或终端没重开检查安装时是否勾选 Add to PATH手动修改环境变量后重开终端图像全黑相机朝向错了射线和场景没有交叉或亮度计算结果为 0先用简单调试把背景色改成红色或纯白色看背景是否生效再检查射线方向是否朝 z 负半轴图像上下颠倒视口坐标的 y 方向没有反转屏幕坐标向下增长世界坐标向上增长注意worldY 1 - ...球体看起来是变形的椭圆没有乘宽高比 aspect在生成射线时乘上aspect W / H阴影区域出现杂乱噪点/小痘痘阴影射线没有加偏移产生了自相交交点沿法线方向偏移一个小量比如point point normal * 1e-4反射太亮/过曝反射颜色没有衰减或递归深度内能量叠加过多反射颜色乘以一个反射率系数0.6~0.9并限制总亮度不超过 255渲染速度很慢每像素只发一条射线但场景物体多了依然慢先用小尺寸测试后面可以加 numpy 向量化、包围盒或空间加速结构高光位置不对半程向量公式写错或视角方向用成原始射线方向检查 viewDir 是否从交点指向相机即camera - point归一化一个像素的颜色偏暗法线方向反了漫反射点乘出现负值被截断输出法线方向到颜色通道来可视化确认法线朝向场景外侧这里我要重点说一个排查思路渲染问题很难通过看数字定位把中间结果可视化是最有效的办法。比如把法线直接当成 RGB 输出如果法线坐标范围是 -1 到 1映射到 0 到 255 后你会看到球体呈现红绿蓝渐变这是判断几何是否正确的最快手段。同理也可以把“是否击中物体”输出成黑白图检查射线生成和求交环节是否正确。调试图形程序最忌讳对着一个黑屏瞎猜。5. 性能优化与后续扩展思路5.1 超采样抗锯齿我第一版程序每像素只发一条射线所以渲染出来的物体边缘有很明显的锯齿尤其是球体边缘的圆弧放大看就像台阶。最简单的抗锯齿方法是超采样SSAA对每个像素内部采样多个点比如 2×2 或 4×4取得多个颜色后平均。代价是渲染时间直接变成原来的 4 倍或 16 倍。我的建议是先用 2×2 采样跑一次如果锯齿减轻到可接受范围就不必开到 4×4毕竟离线渲染也是很讲究性价比的。5.2 加速结构为什么三角形网格需要 BVH 或 Grid当前场景里只有几十个物体线性扫描所有物体求交完全没问题。但现实场景里三角形动辄上百万个线性扫描的复杂度是 O(N)完全不可行。这时候需要用空间加速结构比如 BVH包围体层次结构、均匀网格或 KD-Tree。思路都是把空间划分成树形结构射线先和根节点的大包围盒求交如果没碰到就直接跳过整个子树命中时再往下递归。这样可以把碰撞查询复杂度从 O(N) 降到 O(log N)。BVH 的原理有点像快递分拣中心先按照地区把包裹分类再按城市分再按街道分。查询一条射线时先在省市级别的“包围盒”里排除掉大部分不可能命中的区域只进入可能命中的子树继续检查避免逐条街道都跑一遍。5.3 从光追到路径追踪现实感从哪来我们这个程序是 Whitted-Style 光线追踪只处理镜面反射不做漫反射间的互相照亮所以阴影是硬的漫反射面之间的颜色渗透是缺失的。要得到更像照片的渲染需要进入路径追踪领域在交点处按 BRDF 概率生成多个方向的光线递归采样用蒙特卡洛方法估算光照积分。每个像素可能需要数百条甚至上千条路径才能收敛这就是为什么真正的离线渲染器渲染一帧要好几分钟甚至几十小时。如果你对这个方向感兴趣我建议下一步做这样几件事先给漫反射表面加一层“粗糙反射”用随机扰动反射方向来模拟哑光材质然后实现球体的折射斯涅尔定律做一个透明的玻璃球最后再把每像素采样数当成可配置参数观察从 1 到 64 的效果差异。每一步都能肉眼看到变化正反馈非常强。这个程序虽然叫“相关程序-1”但光追这口井深得很第一版只是个开始。我在实际动手写的时候最大的体会是把每个公式从纸上搬到代码里的过程才是真正理解它的过程。你不用急着追求真实感或性能先把球体、平面、阴影、反射这四关打通之后无论是学路径追踪、实时光追还是 GPU 并行渲染都会轻松很多。本文还有配套的精品资源点击获取