# -*- coding: utf-8 -*- """ 简易三角形康奈尔盒渲染器(递归反射版本) 特性: - 只用三角形面片构建场景(墙面、灯、两个盒子) - Camera 视口被视为一个平面(理论上可拆成两个三角形) - 漫反射表面:不递归,直接光照 - 镜面(金属)表面:递归反射,模拟金属反射 - 输出 PPM 图片:cornell.ppm """ import math # ====================== # 基础向量运算 # ====================== def v_add(a, b): return (a[0] + b[0], a[1] + b[1], a[2] + b[2]) def v_sub(a, b): return (a[0] - b[0], a[1] - b[1], a[2] - b[2]) def v_mul(a, s): # 向量 * 标量 return (a[0] * s, a[1] * s, a[2] * s) def v_mul_comp(a, b): # 分量乘(用于颜色调制) return (a[0] * b[0], a[1] * b[1], a[2] * b[2]) def v_dot(a, b): return a[0]*b[0] + a[1]*b[1] + a[2]*b[2] def v_cross(a, b): return ( a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0] ) def v_length(a): return math.sqrt(v_dot(a, a)) def v_norm(a): l = v_length(a) if l == 0: return (0.0, 0.0, 0.0) return (a[0]/l, a[1]/l, a[2]/l) def v_reflect(d, n): # 反射方向:r = d - 2*(d·n)*n dn = v_dot(d, n) return v_sub(d, v_mul(n, 2.0 * dn)) # ====================== # 材质和三角形定义 # ====================== class Material: def __init__(self, color=(1.0, 1.0, 1.0), emission=(0.0, 0.0, 0.0), kind="diffuse", reflectivity=1.0): """ kind: "diffuse" 或 "mirror" color: 基础颜色/反射率 emission: 自发光颜色(光源) reflectivity: 镜面反射强度(0~1) """ self.color = color self.emission = emission self.kind = kind self.reflectivity = reflectivity class Triangle: def __init__(self, v0, v1, v2, material): self.v0 = v0 self.v1 = v1 self.v2 = v2 self.material = material # 预计算几何法线 self.normal = v_norm(v_cross(v_sub(v1, v0), v_sub(v2, v0))) def intersect(self, ray_o, ray_d, eps=1e-6): """ Möller–Trumbore 三角形求交 返回 (t, u, v) 或 None """ v0, v1, v2 = self.v0, self.v1, self.v2 edge1 = v_sub(v1, v0) edge2 = v_sub(v2, v0) h = v_cross(ray_d, edge2) a = v_dot(edge1, h) if -eps < a < eps: return None # 平行 f = 1.0 / a s = v_sub(ray_o, v0) u = f * v_dot(s, h) if u < 0.0 or u > 1.0: return None q = v_cross(s, edge1) v = f * v_dot(ray_d, q) if v < 0.0 or u + v > 1.0: return None t = f * v_dot(edge2, q) if t > eps: return (t, u, v) return None # ====================== # 场景构建(康奈尔盒) # ====================== def make_quad(v00, v10, v11, v01, material): """ 用两个三角形表示一个四边形: v00----v10 | / | / v01----v11 """ return [ Triangle(v00, v10, v11, material), Triangle(v00, v11, v01, material) ] def add_box(x0, x1, y0, y1, z0, z1, material): """ 用 12 个三角形(6 个面 * 2)构造轴对齐长方体 """ tris = [] # 8 个顶点 p000 = (x0, y0, z0) p001 = (x0, y0, z1) p010 = (x0, y1, z0) p011 = (x0, y1, z1) p100 = (x1, y0, z0) p101 = (x1, y0, z1) p110 = (x1, y1, z0) p111 = (x1, y1, z1) # +X 面 tris += make_quad(p100, p110, p111, p101, material) # -X 面 tris += make_quad(p000, p001, p011, p010, material) # +Y 面(顶面) tris += make_quad(p010, p011, p111, p110, material) # -Y 面(底面) tris += make_quad(p000, p100, p101, p001, material) # +Z 面 tris += make_quad(p001, p101, p111, p011, material) # -Z 面 tris += make_quad(p000, p010, p110, p100, material) return tris def build_cornell_scene(): """ 构建一个 0~1 立方体空间内的简易康奈尔盒 - 左墙红色,右墙绿色,其余白色 - 顶部一个小矩形面光源 - 场景中两个盒子:一个漫反射,一个金属镜面 """ scene = [] # 材质 white = Material(color=(0.8, 0.8, 0.8), kind="diffuse") red = Material(color=(0.75, 0.15, 0.15), kind="diffuse") green = Material(color=(0.15, 0.75, 0.15), kind="diffuse") light_mat = Material(color=(1.0, 1.0, 1.0), emission=(12.0, 12.0, 12.0), # 强光 kind="diffuse") metal = Material(color=(0.9, 0.9, 0.9), kind="mirror", reflectivity=0.95) grey_box = Material(color=(0.75, 0.75, 0.75), kind="diffuse") # 盒子边界 minc, maxc = 0.0, 1.0 # 地板 y=0 scene += make_quad( (minc, 0.0, minc), (maxc, 0.0, minc), (maxc, 0.0, maxc), (minc, 0.0, maxc), white ) # 天花板 y=1 scene += make_quad( (minc, 1.0, minc), (minc, 1.0, maxc), (maxc, 1.0, maxc), (maxc, 1.0, minc), white ) # 后墙 z=1 scene += make_quad( (minc, minc, 1.0), (maxc, minc, 1.0), (maxc, maxc, 1.0), (minc, maxc, 1.0), white ) # 左墙 x=0 (红) scene += make_quad( (0.0, minc, minc), (0.0, minc, maxc), (0.0, maxc, maxc), (0.0, maxc, minc), red ) # 右墙 x=1 (绿) scene += make_quad( (1.0, minc, minc), (1.0, maxc, minc), (1.0, maxc, maxc), (1.0, minc, maxc), green ) # 顶部面光源(天花板中间一小块) lx0, lx1 = 0.35, 0.65 lz0, lz1 = 0.35, 0.65 ly = 0.999 # 略低于天花板避免数值问题 light_tris = make_quad( (lx0, ly, lz0), (lx1, ly, lz0), (lx1, ly, lz1), (lx0, ly, lz1), light_mat ) scene += light_tris # 低盒子(金属反射) scene += add_box( 0.20, 0.50, # x 0.0, 0.30, # y 0.20, 0.50, # z metal ) # 高盒子(漫反射) scene += add_box( 0.55, 0.85, 0.0, 0.70, 0.55, 0.85, grey_box ) return scene # ====================== # 光线追踪核心 # ====================== def find_closest_hit(scene, ray_o, ray_d): """ 在所有三角形中找到最近交点 返回 (tri, t, hit_pos, hit_normal) 或 None """ closest_t = float('inf') hit_tri = None hit_pos = None hit_normal = None for tri in scene: res = tri.intersect(ray_o, ray_d) if res is None: continue t, u, v = res if t < closest_t: closest_t = t # 交点 hit_pos = v_add(ray_o, v_mul(ray_d, t)) # 几何法线拷贝一份 n = tri.normal # 确保法线朝向入射光线的反方向 if v_dot(n, ray_d) > 0: n = v_mul(n, -1.0) hit_normal = n hit_tri = tri if hit_tri is None: return None return hit_tri, closest_t, hit_pos, hit_normal def build_lights(scene): """ 从场景中提取所有发光三角形 """ lights = [] for tri in scene: if (tri.material.emission[0] > 0 or tri.material.emission[1] > 0 or tri.material.emission[2] > 0): lights.append(tri) return lights def triangle_center(tri): return ( (tri.v0[0] + tri.v1[0] + tri.v2[0]) / 3.0, (tri.v0[1] + tri.v1[1] + tri.v2[1]) / 3.0, (tri.v0[2] + tri.v1[2] + tri.v2[2]) / 3.0, ) def trace_ray(scene, lights, ray_o, ray_d, depth, max_depth=4, eps=1e-4): """ 核心递归光线追踪函数 - 只在 kind='mirror' 时递归 - kind='diffuse' 时只做直接光照,不再递归 """ if depth > max_depth: return (0.0, 0.0, 0.0) hit = find_closest_hit(scene, ray_o, ray_d) if hit is None: # 背景颜色(黑) return (0.0, 0.0, 0.0) tri, t, hit_pos, n = hit mat = tri.material # 自发光(光源),直接返回其发光颜色(主要用于第一次命中) color = mat.emission # 漫反射:只做一次直接光照 if mat.kind == "diffuse": direct = (0.0, 0.0, 0.0) for light in lights: # 简化:只用光源三角形中心一个点做光照 & 阴影测试 lp = triangle_center(light) to_light = v_sub(lp, hit_pos) dist2 = v_dot(to_light, to_light) dist = math.sqrt(dist2) l_dir = v_norm(to_light) # 阴影检测:从 hit_pos 向 lp 打一条 shadow ray shadow_o = v_add(hit_pos, v_mul(n, eps)) shadow_hit = find_closest_hit(scene, shadow_o, l_dir) blocked = False if shadow_hit is not None: _, t_shadow, _, _ = shadow_hit if t_shadow < dist - eps: blocked = True if not blocked: # 漫反射 Lambert 项 lambert = max(0.0, v_dot(n, l_dir)) if lambert > 0: # 简单地把光源发光乘以漫反射颜色和 lambert # 不严格做 1/r^2 衰减,只做一个系数缩放 # 可以适当乘个缩放系数让亮度合适 intensity = 1.0 # contribution = mat.color * light.emission * lambert * intensity c = v_mul_comp(mat.color, light.material.emission) c = v_mul(c, lambert * intensity) direct = v_add(direct, c) color = v_add(color, direct) return color # 镜面(金属)表面:计算反射并递归 if mat.kind == "mirror": refl_dir = v_reflect(ray_d, n) refl_o = v_add(hit_pos, v_mul(n, eps)) refl_col = trace_ray(scene, lights, refl_o, refl_dir, depth + 1, max_depth, eps) # 反射颜色 = 本身颜色 * 反射颜色 * 反射率 refl_col = v_mul_comp(mat.color, refl_col) refl_col = v_mul(refl_col, mat.reflectivity) color = v_add(color, refl_col) return color # 其他类型(未用到),直接返回自发光 return color # ====================== # 渲染主函数 # ====================== def clamp(x, lo=0.0, hi=1.0): return max(lo, min(hi, x)) def gamma_correct(c, gamma=2.2): inv = 1.0 / gamma return (c[0] ** inv, c[1] ** inv, c[2] ** inv) def render(): # 图像分辨率 width = 400 height = 400 # 构建场景 scene = build_cornell_scene() lights = build_lights(scene) # 相机设置 cam_pos = (0.5, 0.5, -1.5) # 在盒子前方 look_at = (0.5, 0.5, 0.0) # 看向盒子中心 up = (0.0, 1.0, 0.0) fov = 40.0 # 视角(度) aspect = width / float(height) scale = math.tan(math.radians(fov * 0.5)) # 构建相机坐标系 forward = v_norm(v_sub(look_at, cam_pos)) right = v_norm(v_cross(forward, up)) true_up = v_cross(right, forward) framebuffer = [[(0.0, 0.0, 0.0) for _ in range(width)] for _ in range(height)] for y in range(height): print(f"Rendering line {y+1}/{height} ...", end="\r") for x in range(width): # 像素中心 NDC 坐标 [-1,1] px = (2.0 * ((x + 0.5) / width) - 1.0) * aspect * scale py = (1.0 - 2.0 * ((y + 0.5) / height)) * scale # 在相机坐标系中构造方向 dir_cam = v_add( v_add(v_mul(right, px), v_mul(true_up, py)), forward ) ray_d = v_norm(dir_cam) ray_o = cam_pos col = trace_ray(scene, lights, ray_o, ray_d, depth=0, max_depth=4) # gamma 校正 col = gamma_correct(( clamp(col[0]), clamp(col[1]), clamp(col[2]) )) framebuffer[y][x] = col print("\n渲染完成,正在写入文件 cornell.ppm ...") # 输出 PPM(文本格式) with open("cornell.ppm", "w") as f: f.write(f"P3\n{width} {height}\n255\n") for y in range(height): for x in range(width): r, g, b = framebuffer[y][x] ir = int(255.99 * clamp(r)) ig = int(255.99 * clamp(g)) ib = int(255.99 * clamp(b)) f.write(f"{ir} {ig} {ib}\n") print("写入完成:cornell.ppm") if __name__ == "__main__": render()