464 lines
12 KiB
Python
464 lines
12 KiB
Python
# -*- 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()
|