2019年5月16日木曜日

Python - Planes - Refactoring Shapes, Implementing Spheres

The Ray Tracer Challenge: A Test-Driven Guide to Your First 3D Renderer (Jamis Buck(著)、Pragmatic Bookshelf)、Chapter 9(Planes)のPut It Together(124)を取り組んでみる。

コード

Python 3

shapes_test.py

```#!/usr/bin/env python3
from unittest import TestCase, main
from shapes import Shape
from transformations import translation
from matrices import IDENTITY_MATRIX, Matrix
from materials import Material
from rays import Ray
from tuples import Point, Vector
from transformations import scaling

class ShapeTest(TestCase):
def setUp(self):
self.shape = Shape(material=Material())

def tearDown(self):
pass

def test_default_transformation(self):
self.assertEqual(self.shape.transform, IDENTITY_MATRIX)

def test_material(self):
self.assertEqual(self.shape.material.ambient, 0.1)
self.assertEqual(self.shape.material, Material())

def test_transform(self):
self.assertEqual(Shape().transform,
IDENTITY_MATRIX)
s = Shape()
t = translation(2, 3, 4)
s.transform = t
self.assertEqual(s.transform, t)

if __name__ == '__main__':
main()
```

shapes.py

```from matrices import Matrix, IDENTITY_MATRIX
from materials import Material

class Shape:
def __init__(self, transform=None, material=None):
if transform is None:
self.transform = IDENTITY_MATRIX
else:
self.transform = transform
if material is None:
self.material = Material()
else:
self.material = material

def __repr__(self):
return f'{self.__class__.__name__}({self.transform},{self.material})'

def intersect(self, ray):
raise NotImplementedError()

def normal_at(self, point):
raise NotImplementedError()
```

spheres_test.py

```#!/usr/bin/env python3
from unittest import TestCase, main
from tuples import Point, Vector
from matrices import Matrix
from rays import Ray
from spheres import Sphere
from transformations import translation, scaling
from materials import Material
import math

class SpheresTest(TestCase):
def setUp(self):
pass

def tearDown(self):
pass

def test_sphere(self):
r = Ray(Point(0, 0, -5), Vector(0, 0, 1))
s = Sphere()
xs = s.intersect(r)
for a, b in [(len(xs), 2), (xs[0].obj, s), (xs[1].obj, s)]:
self.assertEqual(a, b)

def test_intersect(self):
r = Ray(Point(0, 0, -5), Vector(0, 0, 1))
s = Sphere()
xs = s.intersect(r)
self.assertEqual(len(xs), 2)
for i, (a, b) in enumerate(zip(xs, [4, 6])):
self.assertEqual(a.t, b)

def test_intersect_target(self):
r = Ray(Point(0, 1, -5), Vector(0, 0, 1))
s = Sphere()
xs = s.intersect(r)
self.assertEqual(len(xs), 2)
for x in xs:
self.assertEqual(x.t, 5)

def test_intersect_misses(self):
r = Ray(Point(0, 2, -5), Vector(0, 0, 1))
s = Sphere()
xs = s.intersect(r)
self.assertEqual(len(xs), 0)

def test_intersect_inside(self):
r = Ray(Point(0, 0, 0), Vector(0, 0, 1))
s = Sphere()
xs = s.intersect(r)
self.assertEqual(len(xs), 2)
for a, b in zip(xs, [-1, 1]):
self.assertEqual(a.t, b)

def test_intersect_behind(self):
r = Ray(Point(0, 0, 5), Vector(0, 0, 1))
s = Sphere()
xs = s.intersect(r)
for a, b in zip(xs, [-6.0, -4.0]):
self.assertEqual(a.t, b)

def test_intersect_scaled_with_ray(self):
r = Ray(Point(0, 0, -5), Vector(0, 0, 1))
s = Sphere()
s.transform = scaling(2, 2, 2)
xs = s.intersect(r)
for a, b in [(len(xs), 2), (xs[0].t, 3), (xs[1].t, 7)]:
self.assertEqual(a, b)

def test_normal_at(self):
s = Sphere()
tests = [((1, 0, 0), (1, 0, 0)),
((0, 1, 0), (0, 1, 0)),
((0, 0, 1), (0, 0, 1)),
((1 / math.sqrt(3), 1 / math.sqrt(3), 1 / math.sqrt(3)),
(1 / math.sqrt(3), 1 / math.sqrt(3), 1 / math.sqrt(3)))]
for a, b in tests:
self.assertEqual(s.normal_at(Point(*a)), Vector(*b))

def test_normal_at(self):
s = Sphere()
n = s.normal_at(Point(1 / math.sqrt(3), 1 / math.sqrt(3),
1 / math.sqrt(3)))
self.assertEqual(n, n.normalize())

def test_normal_at_translated(self):
s = Sphere()
s.transform = translation(0, 1, 0)
n = s.normal_at(Point(0, 1.70711, -0.70711))
self.assertEqual(n, Vector(0, 0.70711, -0.70711))

if __name__ == '__main__':
main()
```

spheres.py

```from shapes import Shape
from tuples import Point
from rays import Ray
from intersections import Intersection, Intersections
from matrices import Matrix
from materials import Material
import math

identity_matrix = Matrix([[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]])

class Sphere(Shape):
def intersect(self, ray: Ray) -> Intersections:
ray = ray.transform(self.transform.inverse())
sphere_to_ray = ray.origin - Point(0, 0, 0)
a = ray.direction.dot(ray.direction)
b = 2 * ray.direction.dot(sphere_to_ray)
c = sphere_to_ray.dot(sphere_to_ray) - 1
discriminant = b ** 2 - 4 * a * c
if discriminant < 0:
return Intersections()
return Intersections(
*[Intersection((-b + c * math.sqrt(discriminant)) / (2 * a), self)
for c in [-1, 1]])

def normal_at(self, world_point: Point) -> Point:
object_point = self.transform.inverse() * world_point
object_normal = object_point - Point(0, 0, 0)
world_normal = self.transform.inverse().transpose() * object_normal
world_normal.w = 0
return world_normal.normalize()
```

planes_test.py

```#!/usr/bin/env python3
from unittest import TestCase, main
from planes import Plane
from tuples import Point, Vector
from rays import Ray

class PlaneTest(TestCase):
def setUp(self):
pass

def tearDown(self):
pass

def test_normal_at_constant_everywhere(self):
plane = Plane()
n1 = plane.normal_at(Point(0, 0, 0))
n2 = plane.normal_at(Point(10, 0, -10))
n3 = plane.normal_at(Point(-5, 0, 150))
for n in [n1, n2, n3]:
self.assertEqual(n, Vector(0, 1, 0))

def test_intersect_ray_parallel_to_plane(self):
plane = Plane()
ray = Ray(Point(0, 10, 0), Vector(0, 0, 1))
xs = plane.intersect(ray)
self.assertEqual(len(xs), 0)

def test_intersect_from_above(self):
plane = Plane()
ray = Ray(Point(0, 1, 0), Vector(0, -1, 0))
xs = plane.intersect(ray)
for a, b in [(len(xs), 1),
(xs[0].t, 1),
(xs[0].obj, plane)]:
self.assertEqual(a, b)

def test_intersect_from_below(self):
plane = Plane()
ray = Ray(Point(0, -1, 0), Vector(0, 1, 0))
xs = plane.intersect(ray)
for a, b in [(len(xs), 1),
(xs[0].t, 1),
(xs[0].obj, plane)]:
self.assertEqual(a, b)

if __name__ == '__main__':
main()
```

planes.py

```from shapes import Shape
from tuples import EPSILON, Vector
from intersections import Intersection, Intersections

class Plane(Shape):
def normal_at(self, point):
return self.transform.inverse() * Vector(0, 1, 0)

def intersect(self, ray):
r = ray.transform(self.transform.inverse())
if abs(r.direction.y) < EPSILON:
return Intersections()
t = -r.origin.y / r.direction.y
i = Intersection(t, self)
return Intersections(i)
```

sample.py

```#!/usr/bin/env python3
import math
import time
from planes import Plane
from spheres import Sphere
from materials import Material
from tuples import Point, Vector, Color
from camera import Camera
from transformations import view_transform, translation, scaling
from transformations import rotation_x, rotation_y
from world import World
from lights import Light

floor = Plane(material=Material(Color(1, 0.9, 0.9), specular=0))
middle = Sphere(translation(-0.5, 1, 0.5),
Material(Color(0.1, 1, 0.5),
diffuse=0.7,
specular=0.3))
right = Sphere(translation(1.5, 0.5, -0.5) * scaling(0.5, 0.5, 0.5),
Material(Color(0.5, 1, 0.1),
diffuse=0.7,
specular=0.3))
left = Sphere(translation(-1.5, 0.33, -0.75) * scaling(0.33, 0.33, 0.33),
Material(Color(1, 0.8, 0.1),
diffuse=0.7,
specular=0.3))
other = Sphere(translation(0, 0.7, -1) * scaling(0.6, 0.6, 0.6),
Material(Color(1, 0, 0), diffuse=0.7, specular=0.3))

backdrop = Plane(translation(0, 0, 5) *
rotation_x(math.pi / 2),
Material(Color(0, 0, 1), specular=0.3))
camera = Camera(250, 125, math.pi / 3,
transform=view_transform(Point(0, 1.5, -5),
Point(0, 1, 0),
Vector(0, 1, 0)))

world = World([floor, backdrop, left, middle, right, other],
Light(Point(-10, 10, -10), Color(1, 1, 1)))

print('ファイル名, rendering time(秒)')
start = time.time()
canvas = camera.render(world)
start = time.time()
filename = 'sample1.ppm'
with open(filename, 'w') as f:
canvas.to_ppm(f)
seconds_to_ppm = time.time() - start

# hexagonal-shaped room, ceiling, embedded sphere
angle = 2 * math.pi / 6
colors = [Color(1, 0, 0), Color(0, 1, 0), Color(0, 0, 1), Color(0.5, 0.5, 0),
Color(0.5, 0, 0.5), Color(0, 0.5, 0.5)]

planes = [Plane(translation(5 * math.sin(angle * i),
0,
5 * math.cos(angle * i)) *
rotation_y(i * angle) *
rotation_x(math.pi / 2),
Material(color))
for i, color in enumerate(colors)]
planes.append(Plane(translation(0, 5, 0),
Material(Color(1, 0.9, 0.9), specular=0)))
sphere = Sphere(translation(0, 5, 0) * scaling(2, 2, 2),
Material(Color(1, 0.8, 0.1),
diffuse=0.7,
specular=0.3))
camera = Camera(250, 125, math.pi / 3,
view_transform(Point(-1, -5, -1),
Point(0, 0, 0),
Vector(0, 1, 0)))
world = World(planes + [sphere], Light(Point(0, 0, 0), Color(1, 1, 1)))
start = time.time()
canvas = camera.render(world)
start = time.time()
filename = 'sample2.ppm'
with open(filename, 'w') as f:
canvas.to_ppm(f)
seconds_to_ppm = time.time() - start
```

```C:\Users\...>py shapes_test.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

C:\Users\...>py spheres_test.py
.........
----------------------------------------------------------------------
Ran 9 tests in 0.007s

OK

C:\Users\...>py planes_test.py
....
----------------------------------------------------------------------
Ran 4 tests in 0.004s

OK

C:\Users\...>py sample.py
ファイル名, rendering time(秒)
sample1.ppm,282.1003210544586
sample2.ppm,354.80750012397766

C:\Users\...>
```