Python 光线追踪在相机附近扭曲物体形状

5 投票
1 回答
2361 浏览
提问于 2025-04-18 01:03

问题

我最近发现了一个很棒的纯Python光线追踪脚本,链接在这里。为了让它更方便,我对它做了一些扩展。但是,有时候物体的形状会变形,我在想有没有懂光线追踪或3D的人能告诉我这可能是什么原因?

一些信息

我正在测试的场景是一个地面平面,上面放着三个彩色的球体。当相机从上方或某个角度、一定距离俯视场景时,效果看起来很好(见前两张图片);但是,当相机靠近地面和物体时,这些球体的形状会变得扁长,像是被拉向天空一样(见第三张图片)。需要注意的是,第三张图片中的相机有点倒过来,这是因为我还在摸索如何控制相机,不知道怎么把它“旋转”到正常的位置;它似乎总是自动朝向球体或光源所在的区域,只有当我改变一些参数时,它才会朝不同的方向看。

enter image description here

enter image description here

enter image description here

enter image description here

我还在努力理解我找到的原始代码,并以此为基础编写我的代码,所以我不太确定问题出在哪里,可能是原作者在光线追踪方法或思路上的问题。我附上了我的模块脚本的完整代码,按F5就能运行,如果有人愿意挑战一下。图像渲染需要PIL库,如果你想调整相机的位置,可以查看Camera类,并在“normaltest”函数中更改它的选项。

更新

有人指出,在运行脚本时,第三张图片中的问题没有重现。我现在已经更改了normaltest函数中的相机位置,以便重现这个问题(见新的第四张图片,看看应该是什么样子)。如果你在想为什么光似乎从球体中射出来,那是因为我把光源放在了它们之间的某个地方。

我开始觉得问题可能出在相机上,我对它的理解还不够。

  • 相机选项中的缩放、x角度和y角度可能并不像它们的名字所暗示的那样;这只是我根据它们在我改变时的表现给它们起的名字。最开始它们不是变量,而是计算中的一些常数,需要手动更改。具体来说,它们用于在renderScene函数的第218行定义和生成穿过场景的光线。
  • 例如,有时候当我改变缩放值时,它也会改变相机的方向和位置。
  • 在原始代码中,相机只是被定义为一个没有方向的点(x角度和y角度最开始只是静态数字,没有定义它们的选项),而且几乎总是自动朝向物体。
  • 我找不到方法来“旋转”或倾斜相机。

你也可以尝试把相机从当前的z坐标2提高到5,这个变化很小,但能显著改善变形的效果(虽然仍然不好),所以靠近地面或随之而来的角度变化似乎在其中起了一些作用。

"""
Pure Python ray-tracer :)
taken directly from http://pastebin.com/f8f5ghjz with modifications
another good one alternative at http://www.hxa.name/minilight/
some more equations for getting intersection with other 3d geometries, https://www.cl.cam.ac.uk/teaching/1999/AGraphHCI/SMAG/node2.html#SECTION00023200000000000000
"""

#IMPORTS
from math import sqrt, pow, pi
import time
import PIL,PIL.Image

#GEOMETRIES
class Vector( object ):

    def __init__(self,x,y,z):
        self.x = x
        self.y = y
        self.z = z

    def dot(self, b):
        return self.x*b.x + self.y*b.y + self.z*b.z

    def cross(self, b):
        return (self.y*b.z-self.z*b.y, self.z*b.x-self.x*b.z, self.x*b.y-self.y*b.x)

    def magnitude(self):
        return sqrt(self.x**2+self.y**2+self.z**2)

    def normal(self):
        mag = self.magnitude()
        return Vector(self.x/mag,self.y/mag,self.z/mag)

    def __add__(self, b):
        return Vector(self.x + b.x, self.y+b.y, self.z+b.z)

    def __sub__(self, b):
        return Vector(self.x-b.x, self.y-b.y, self.z-b.z)

    def __mul__(self, b):
        assert type(b) == float or type(b) == int
        return Vector(self.x*b, self.y*b, self.z*b)     

class Sphere( object ):

    def __init__(self, center, radius, color):
        self.c = center
        self.r = radius
        self.col = color

    def intersection(self, l):
        q = l.d.dot(l.o - self.c)**2 - (l.o - self.c).dot(l.o - self.c) + self.r**2
        if q < 0:
            return Intersection( Vector(0,0,0), -1, Vector(0,0,0), self)
        else:
            d = -l.d.dot(l.o - self.c)
            d1 = d - sqrt(q)
            d2 = d + sqrt(q)
            if 0 < d1 and ( d1 < d2 or d2 < 0):
                return Intersection(l.o+l.d*d1, d1, self.normal(l.o+l.d*d1), self)
            elif 0 < d2 and ( d2 < d1 or d1 < 0):
                return Intersection(l.o+l.d*d2, d2, self.normal(l.o+l.d*d2), self)
            else:
                return Intersection( Vector(0,0,0), -1, Vector(0,0,0), self)    

    def normal(self, b):
        return (b - self.c).normal()

class Cylinder( object ):

    "just a copy of sphere, needs work. maybe see http://stackoverflow.com/questions/4078401/trying-to-optimize-line-vs-cylinder-intersection"

    def __init__(self, startpoint, endpoint, radius, color):
        self.s = startpoint
        self.e = endpoint
        self.r = radius
        self.col = color

    def intersection(self, l):
        q = l.d.dot(l.o - self.c)**2 - (l.o - self.c).dot(l.o - self.c) + self.r**2
        if q < 0:
            return Intersection( Vector(0,0,0), -1, Vector(0,0,0), self)
        else:
            d = -l.d.dot(l.o - self.c)
            d1 = d - sqrt(q)
            d2 = d + sqrt(q)
            if 0 < d1 and ( d1 < d2 or d2 < 0):
                return Intersection(l.o+l.d*d1, d1, self.normal(l.o+l.d*d1), self)
            elif 0 < d2 and ( d2 < d1 or d1 < 0):
                return Intersection(l.o+l.d*d2, d2, self.normal(l.o+l.d*d2), self)
            else:
                return Intersection( Vector(0,0,0), -1, Vector(0,0,0), self)    

    def normal(self, b):
        return (b - self.c).normal()

class LightBulb( Sphere ):
        pass

class Plane( object ):
    "infinite, no endings"
    def __init__(self, point, normal, color):
        self.n = normal
        self.p = point
        self.col = color

    def intersection(self, l):
        d = l.d.dot(self.n)
        if d == 0:
            return Intersection( vector(0,0,0), -1, vector(0,0,0), self)
        else:
            d = (self.p - l.o).dot(self.n) / d
            return Intersection(l.o+l.d*d, d, self.n, self)

class Rectangle( object ):
    "not done. like a plane, but is limited to the shape of a defined rectangle"
    def __init__(self, point, normal, color):
        self.n = normal
        self.p = point
        self.col = color

    def intersection(self, ray):
        desti = ray.dest.dot(self.n)
        if desti == 0:
                        #??
            return Intersection( vector(0,0,0), -1, vector(0,0,0), self)
        else:
            desti = (self.p - ray.orig).dot(self.n) / desti
            return Intersection(ray.orig+ray.desti*desti, desti, self.n, self)

class RectangleBox( object ):
        "not done. consists of multiple rectangle objects as its sides"
        pass

class AnimatedObject( object ):

        def __init__(self, *objs):
                self.objs = objs

        def __iter__(self):
                for obj in self.objs:
                        yield obj

        def __getitem__(self, index):
                return self.objs[index]

        def reverse(self):
                self.objs = [each for each in reversed(self.objs)]
                return self

#RAY TRACING INTERNAL COMPONENTS
class Ray( object ):

    def __init__(self, origin, direction):
        self.o = origin
        self.d = direction

class Intersection( object ):
    "keeps a record of a known intersection bw ray and obj?"
    def __init__(self, point, distance, normal, obj):
        self.p = point
        self.d = distance
        self.n = normal
        self.obj = obj

def testRay(ray, objects, ignore=None):
    intersect = Intersection( Vector(0,0,0), -1, Vector(0,0,0), None)

    for obj in objects:
        if obj is not ignore:
            currentIntersect = obj.intersection(ray)
            if currentIntersect.d > 0 and intersect.d < 0:
                intersect = currentIntersect
            elif 0 < currentIntersect.d < intersect.d:
                intersect = currentIntersect
    return intersect

def trace(ray, objects, light, maxRecur):
    if maxRecur < 0:
        return (0,0,0)
    intersect = testRay(ray, objects)       
    if intersect.d == -1:
        col = vector(AMBIENT,AMBIENT,AMBIENT)
    elif intersect.n.dot(light - intersect.p) < 0:
        col = intersect.obj.col * AMBIENT
    else:
        lightRay = Ray(intersect.p, (light-intersect.p).normal())
        if testRay(lightRay, objects, intersect.obj).d == -1:
            lightIntensity = 1000.0/(4*pi*(light-intersect.p).magnitude()**2)
            col = intersect.obj.col * max(intersect.n.normal().dot((light - intersect.p).normal()*lightIntensity), AMBIENT)
        else:
            col = intersect.obj.col * AMBIENT
    return col

def gammaCorrection(color,factor):
    return (int(pow(color.x/255.0,factor)*255),
            int(pow(color.y/255.0,factor)*255),
            int(pow(color.z/255.0,factor)*255))

#USER FUNCTIONS
class Camera:

    def __init__(self, cameraPos, zoom=50.0, xangle=-5, yangle=-5):
        self.pos = cameraPos
        self.zoom = zoom
        self.xangle = xangle
        self.yangle = yangle

def renderScene(camera, lightSource, objs, imagedims, savepath):
        imgwidth,imgheight = imagedims
        img = PIL.Image.new("RGB",imagedims)
        #objs.append( LightBulb(lightSource, 0.2, Vector(*white)) )
        print "rendering 3D scene"
        t=time.clock()
        for x in xrange(imgwidth):
                #print x
                for y in xrange(imgheight):
                        ray = Ray( camera.pos, (Vector(x/camera.zoom+camera.xangle,y/camera.zoom+camera.yangle,0)-camera.pos).normal())
                        col = trace(ray, objs, lightSource, 10)
                        img.putpixel((x,imgheight-1-y),gammaCorrection(col,GAMMA_CORRECTION))
        print "time taken", time.clock()-t
        img.save(savepath)

def renderAnimation(camera, lightSource, staticobjs, animobjs, imagedims, savepath, saveformat):
        "NOTE: savepath should not have file extension, but saveformat should have a dot"
        time = 0
        while True:
                print "time",time
                timesavepath = savepath+"_"+str(time)+saveformat
                objs = []
                objs.extend(staticobjs)
                objs.extend([animobj[time] for animobj in animobjs])
                renderScene(camera, lightSource, objs, imagedims, timesavepath)
                time += 1

#SOME LIGHTNING OPTIONS
AMBIENT = 0.05 #daylight/nighttime
GAMMA_CORRECTION = 1/2.2 #lightsource strength?

#COLORS
red = (255,0,0)
yellow = (255,255,0)
green = (0,255,0)
blue = (0,0,255)
grey = (120,120,120)
white = (255,255,255)
purple = (200,0,200)

def origtest():
        print ""
        print "origtest"
        #BUILD THE SCENE
        imagedims = (500,500)
        savepath = "3dscene_orig.png"
        objs = []
        objs.append(Sphere( Vector(-2,0,-10), 2, Vector(*green)))      
        objs.append(Sphere( Vector(2,0,-10), 3.5, Vector(*red)))
        objs.append(Sphere( Vector(0,-4,-10), 3, Vector(*blue)))
        objs.append(Plane( Vector(0,0,-12), Vector(0,0,1), Vector(*grey)))
        lightSource = Vector(0,10,0)
        camera = Camera(Vector(0,0,20))

        #RENDER
        renderScene(camera, lightSource, objs, imagedims, savepath)

def normaltest():
        print ""
        print "normaltest"
        #BUILD THE SCENE
        """
        the camera is looking down on the surface with the spheres from above
        the surface is like looking down on the xy axis of the xyz coordinate system
        the light is down there together with the spheres, except from one of the sides
        """
        imagedims = (200,200)
        savepath = "3dscene.png"
        objs = []
        objs.append(Sphere( Vector(-4, -2, 1), 1, Vector(*red)))
        objs.append(Sphere( Vector(-2, -2, 1), 1, Vector(*blue)))
        objs.append(Sphere( Vector(-2, -4, 1), 1, Vector(*green)))
        objs.append(Plane( Vector(0,0,0), Vector(0,0,1), Vector(*grey)))
        lightSource = Vector(-2.4, -3, 2)
        camera = Camera(Vector(-19,-19,2), zoom=2.0, xangle=-30, yangle=-30)

        #RENDER
        renderScene(camera, lightSource, objs, imagedims, savepath)

def animtest():
        print ""
        print "falling ball test"
        #BUILD THE SCENE
        imagedims = (200,200)
        savepath = "3d_fallball"
        saveformat = ".png"
        staticobjs = []
        staticobjs.append(Sphere( Vector(-4, -2, 1), 1, Vector(*red)))
        staticobjs.append(Sphere( Vector(-2, -4, 1), 1, Vector(*green)))
        staticobjs.append(Plane( Vector(0,0,0), Vector(0,0,1), Vector(*purple)))
        animobjs = []
        fallingball = AnimatedObject(Sphere( Vector(-2, -2, 20), 1, Vector(*yellow)),
                                     Sphere( Vector(-2, -2, 15), 1, Vector(*yellow)),
                                     Sphere( Vector(-2, -2, 9), 1, Vector(*yellow)),
                                     Sphere( Vector(-2, -2, 5), 1, Vector(*yellow)),
                                     Sphere( Vector(-2, -2, 1), 1, Vector(*yellow)))
        animobjs.append(fallingball)
        lightSource = Vector(-4,-4,10)
        camera = Camera(Vector(0,0,30))

        #RENDER
        renderAnimation(camera, lightSource, staticobjs, animobjs, imagedims, savepath, saveformat)

#RUN TESTS
#origtest()
normaltest()
#animtest()

1 个回答

0

你遇到的问题可能跟这一行代码有关:

ray = Ray( camera.pos, 
     (Vector(
        x/camera.zoom+camera.xangle,
        y/camera.zoom+camera.yangle,
        0)
     -camera.pos)
     .normal())

这里说的“光线”是指从相机位置(不管这个位置怎么定义)出发,沿着XY平面延伸的一条线,这条线会根据xangle和yangle参数进行缩放和移动。

这种方式并不是通常实现透视投影的方法。它更像是一种倾斜/移动相机的效果。一般的透视变换会保持你投影的平面与从相机出发穿过画面中心的光线是垂直的。

使用这段代码时,你有两个选择:要么重写这段代码,要么始终将xangle、yangle、camera.pos.x和camera.pos.y设置为0。否则你会得到奇怪的结果。

从正确的角度来看,这种方式是完全合法的透视效果。只是你在普通相机上是看不到这样的效果的。

撰写回答