def random_in_unit_disk() -> Vec3: point: Vec3 = 2 * Vec3(random.random(), random.random(), 0) - Vec3(1, 1, 0) while point.dot(point) >= 1: point = 2 * Vec3(random.random(), random.random(), 0) - Vec3(1, 1, 0) return point
def color(ray: Ray) -> Vec3: """ Linear interpolation of color based on the y direction. As for hitting the sphere, note that the brightness of an object with respect to its light source is dependent on its normal vectors. The greater the angle between the normal and the light ray, the darker that spot is. Obvious implication: the sphere is brightest where the angle between the normal and the light ray is 0---that is, when the light ray is parallel to the normal. """ t: float = hit_sphere(Vec3(0, 0, -1), 0.5, ray) if t > 0: normal: Vec3 = (ray.point_at_parameter(t) - Vec3(0, 0, -1)).unit_vector() # For all I can tell, what this does is to "scale" the normal such that # (a) there are no negatives and (b) it will not all devolve to just 0, # which leaves us with just a black circle. Multiplying by .5 ensures # that the resulting sphere isn't too dark or too light. # # - Without the scaling factor (0.5), the image is just too bright. # - Without the increments to each component of the normal, the # resulting image will tend to have negative values, which are invalid # in the PPM spec to begin with, and just plain doesn't make sense. return 0.5 * Vec3(normal.x + 1, normal.y + 1, normal.z + 1) unit_direction: Vec3 = ray.direction.unit_vector() # Note that t's definition was pulled up. I guess he was just saving on some # stack space. t = 0.5 * (unit_direction.y + 1) return (UNIT_VEC3 * (1.0 - t)) + (Vec3(0.5, 0.7, 1.0) * t)
def random_unit_sphere_point() -> Vec3: """ Pick a random point inside a unit sphere. To do this, we use a "rejection method": 1. Pick a random point inside a cube with edges in the [-1, 1] range of all axes. Note that the unit sphere is inside this cube and this cube is "easy" to construct programmatically. 2. Check whether the point is inside the unit sphere. If it is not, generate again. Do this until we get a point inside the unit sphere. The time complexity of this procedure is left as an exercise to the reader. """ # Shirley uses the following formula to generate a random point in the # constraints specified: # 2 * rand_vector - unit_vector # Where rand_vector has components that range from [0, 1). While this is # understandable enough, I'm experimenting with random.uniform instead. rand_point: Vec3 = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(-1, 1)) while rand_point.squared_length() >= 1: rand_point = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(-1, 1)) return rand_point
def test_dot(self): a = Vec3(2.0, 1.0, -1.0) b = Vec3(-3.0, 4.0, 1.0) adb = a.dot(b) expected = sum((a.x * b.x, a.y * b.y, a.z * b.z)) self.assertEqual(expected, adb)
def irandom_in_unit_disk() -> Vec3: point: Vec3 = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), 0) while point.dot(point) >= 1: point = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), 0) return point
def test_cross(self): a = Vec3(2.0, 1.0, -1.0) b = Vec3(-3.0, 4.0, 1.0) axb = a.cross(b) self.assertEqual(Vec3(5.0, 1.0, 11.0), axb) bxa = b.cross(a) self.assertEqual(Vec3(-5.0, -1.0, -11.0), bxa)
def color(ray: Ray, world: HittableList) -> Vec3: hit_attempt: Optional[HitRecord] = world.hit(ray, 0.0, sys.float_info.max) if hit_attempt is not None: return 0.5 * Vec3(hit_attempt.normal.x + 1, hit_attempt.normal.y + 1, hit_attempt.normal.z + 1) else: unit_direction: Vec3 = ray.direction.unit_vector() t: float = 0.5 * (unit_direction.y + 1) return ((1.0 - t) * UNIT_VEC3) + (t * Vec3(0.5, 0.7, 1.0))
def color(ray: Ray) -> Vec3: """ Linear interpolation of color based on the y direction. """ if hit_sphere(Vec3(0, 0, -1), 0.5, ray): return Vec3(1, 0, 0) unit_direction: Vec3 = ray.direction.unit_vector() # WOW lots of magic numbers! t: float = 0.5 * (unit_direction.y + 1) return (UNIT_VEC3 * (1.0 - t)) + (Vec3(0.5, 0.7, 1.0) * t)
def random_scene(x_min: int, x_max: int, z_min: int, z_max: int) -> List[Hittable]: # Start off the list with the "earth" sphere. world: List[Hittable] = [ #Sphere(Vec3(0, -1000, 0), 1000, Lambertian(Vec3(0.5, 0.5, 0.5))) ] #for x in range(x_min, x_max): # if len(world) >= 100: # break # for z in range(z_min, z_max): # if len(world) >= 100: # break # material_decider: float = random.random() # # TODO Clarify what the 0.9 constant here is for. # center: Vec3 = Vec3( # x + 0.9 * random.random(), # 0.2, # z + 0.9 * random.random() # ) # # TODO What is Vec3(4, 0.2, 0) for? # if (center - Vec3(4, 0.2, 0)).length() > 0.9: # if material_decider < 0.8: # world.append( # Sphere(center, 0.2, Lambertian(Vec3( # random.random() * random.random(), # random.random() * random.random(), # random.random() * random.random() # ))) # ) # elif material_decider < 0.95: # world.append( # Sphere(center, 0.2, Metal( # Vec3( # 0.5 * (1 + random.random()), # 0.5 * (1 + random.random()), # 0.5 * (1 + random.random()) # ), # 0.5 * random.random() # )) # ) # else: # world.append( # Sphere(center, 0.2, Dielectric(1.5)) # ) world.extend([ Sphere(Vec3(0, 1, 0), 1.0, Dielectric(1.5)), Sphere(Vec3(-4, 1, 0), 1.0, Lambertian(Vec3(0.4, 0.2, 0.1))), Sphere(Vec3(4, 1, 0), 1.0, Metal(Vec3(0.7, 0.6, 0.5), 0.0)) ]) return world
def color(ray: Ray, world: HittableList, depth: int = 0) -> Vec3: # Some reflected rays hit not at zero but at some near-zero value due to # floating point shennanigans. So we try to compensate for that. hit_attempt: Optional[HitRecord] = world.hit(ray, 0.001, sys.float_info.max) if hit_attempt is not None: reflection: ReflectionRecord = hit_attempt.material.scatter( ray, hit_attempt) if depth < 50 and reflection is not None: return reflection.attenuation * color(reflection.scattering, world, depth + 1) else: return Vec3(0, 0, 0) else: unit_direction: Vec3 = ray.direction.unit_vector() t: float = 0.5 * (unit_direction.y + 1) return ((1.0 - t) * UNIT_VEC3) + (t * Vec3(0.5, 0.7, 1.0))
def color(ray: Ray, world: HittableList, depth: int) -> Vec3: # Some reflected rays hit not at zero but at some near-zero value due to # floating point shennanigans. So we try to compensate for that. hit_attempt: Optional[HitRecord] = world.hit(ray, 0.001, sys.float_info.max) if hit_attempt is not None: scattering: ReflectionRecord = hit_attempt.material.scatter(ray, hit_attempt) # FIXME Is it really worthwhile to check if reflection is not None here? # All our Materials assume that a hit has been made, and therefore some # reflection should happen (unless it is Vanta). if depth < 50 and scattering is not None: # Compare this with the hard-coded reflection in 6_matterial. return scattering.attenuation * color(scattering.scattering, world, depth + 1) else: return Vec3(0, 0, 0) else: unit_direction: Vec3 = ray.direction.unit_vector() t: float = 0.5 * (unit_direction.y + 1) return ((1.0 - t) * UNIT_VEC3) + (t * Vec3(0.5, 0.7, 1.0))
def __init__(self, width: int, height: int, default_color: Optional[Vec3] = None): self.width = width self.height = height if default_color is None: default_color = Vec3(0, 0, 0) self.grid = [[default_color for _ in range(width)] for __ in range(height)]
class HitRecord: p: Vec3 t: np.array normal: Vec3 = Vec3(data=(0., 0., 0.)) front_face: np.array = None def set_face_normal(self, r: Ray, outward_normal): self.front_face = r.direction.dot(self.normal) < 0 self.normal = vec_where(self.front_face, outward_normal, outward_normal * -1.)
def hit(self, r: Ray, t_min, t_max) -> HitRecord: oc = r.origin - self.center a = r.direction.squared_length() half_b = oc.dot(r.direction) c = oc.squared_length() - self.radius * self.radius discriminant = half_b * half_b - a * c root = (-half_b + np.sqrt(discriminant)) / a first_root = np.where( (discriminant > 0) & (root > t_min) & (root < t_max), root, t_max) root = (-half_b - np.sqrt(discriminant)) / a second_root = np.where( (discriminant > 0) & (root > t_min) & (root < t_max), root, first_root) p = vec_where(second_root < t_max, r.at(second_root), Vec3(data=(np.Inf, np.Inf, np.Inf))) rec = HitRecord(t=second_root, p=p) rec.normal = vec_where(second_root < t_max, (p - self.center) / self.radius, Vec3(data=(np.Inf, np.Inf, np.Inf))) return rec
def scatter(self, incident_ray: Ray, record: "HitRecord") -> ReflectionRecord: reflected: Vec3 = reflect(incident_ray.direction, record.normal) attenuation: Vec3 = Vec3(1, 1, 1) # These are just placeholders; the following conditional block is their # actual "initial values". outward_normal: Vec3 = Vec3(1, 1, 1) nint: float = 0 cosine: float = 0 if incident_ray.direction.dot(record.normal) > 0: outward_normal = -1 * record.normal nint = self.refractive_index cosine = (self.refractive_index * incident_ray.direction.dot(record.normal) / incident_ray.direction.length()) else: outward_normal = record.normal nint = 1 / self.refractive_index cosine = -(incident_ray.direction.dot(record.normal) / incident_ray.direction.length()) # All this convoluted mix-up with _refracted and refracted is just # for type consistency. refracted: Vec3 = Vec3(0, 0, 0) _refracted: Optional[Vec3] = refract(incident_ray.direction, outward_normal, nint) reflection_probability: float = 1 if _refracted is not None: reflection_probability = self.__schlick_approximation(cosine) refracted = _refracted if reflection_probability == 1: return ReflectionRecord(attenuation, Ray(record.p, reflected)) else: return ReflectionRecord(attenuation, Ray(record.p, refracted))
def color(ray: Ray, world: HittableList) -> Vec3: # Some reflected rays hit not at zero but at some near-zero value due to # floating point shennanigans. So we try to compensate for that. hit_attempt: Optional[HitRecord] = world.hit(ray, 0.001, sys.float_info.max) if hit_attempt is not None: target: Vec3 = hit_attempt.p + hit_attempt.normal + random_unit_sphere_point( ) # FIXME mmm recursion # reflector_rate * reflected_color # So in this case, the matterial is a 50% reflector. return 0.5 * color(Ray(hit_attempt.p, target - hit_attempt.p), world) else: unit_direction: Vec3 = ray.direction.unit_vector() t: float = 0.5 * (unit_direction.y + 1) return ((1.0 - t) * UNIT_VEC3) + (t * Vec3(0.5, 0.7, 1.0))
from src.camera import Camera, PositionableCamera from src.hittable import HitRecord, Hittable, HittableList from src.material import Dielectric, Lambertian, Metal, ReflectionRecord from src.ppm import PPM from src.ray import Ray from src.sphere import Sphere from src.utils import _derive_ppm_filename from src.vec3 import Vec3 from typing import List, Optional import math import random import sys UNIT_VEC3: Vec3 = Vec3(1, 1, 1) def random_scene(x_min: int, x_max: int, z_min: int, z_max: int) -> List[Hittable]: # Start off the list with the "earth" sphere. world: List[Hittable] = [ #Sphere(Vec3(0, -1000, 0), 1000, Lambertian(Vec3(0.5, 0.5, 0.5))) ] #for x in range(x_min, x_max): # if len(world) >= 100: # break # for z in range(z_min, z_max): # if len(world) >= 100: # break # material_decider: float = random.random()
def scatter(self, incident_ray: Ray, record: "HitRecord") -> ReflectionRecord: return ReflectionRecord(Vec3(1, 1, 1), incident_ray)
from random import SystemRandom from src.camera import Camera from src.hittable import HitRecord, Hittable, HittableList from src.ppm import PPM from src.ray import Ray from src.sphere import Sphere from src.utils import _derive_ppm_filename from src.vec3 import Vec3 from typing import List, Optional import sys UNIT_VEC3: Vec3 = Vec3(1.0, 1.0, 1.0) random = SystemRandom() def color(ray: Ray, world: HittableList) -> Vec3: hit_attempt: Optional[HitRecord] = world.hit(ray, 0.0, sys.float_info.max) if hit_attempt is not None: return 0.5 * Vec3(hit_attempt.normal.x + 1, hit_attempt.normal.y + 1, hit_attempt.normal.z + 1) else: unit_direction: Vec3 = ray.direction.unit_vector() t: float = 0.5 * (unit_direction.y + 1) return ((1.0 - t) * UNIT_VEC3) + (t * Vec3(0.5, 0.7, 1.0)) if __name__ == "__main__": width = 400 height = 200 sampling_size = 200
from src.ppm import PPM from src.ray import Ray from src.utils import _derive_ppm_filename from src.vec3 import Vec3 """ We have an abstract, static camera at (0, 0, 0). """ UNIT_VEC3: Vec3 = Vec3(1.0, 1.0, 1.0) def color(ray: Ray) -> Vec3: """ Linear interpolation of color based on the y direction. """ unit_direction: Vec3 = ray.direction.unit_vector() # WOW lots of magic numbers! t: float = 0.5 * (unit_direction.y + 1) return (UNIT_VEC3 * (1.0 - t)) + (Vec3(0.5, 0.7, 1.0) * t) if __name__ == "__main__": width = 400 height = 200 ppm: PPM = PPM(width, height) lower_left_corner: Vec3 = Vec3(-2, -1, -1) h_movement: Vec3 = Vec3(4, 0, 0) v_movement: Vec3 = Vec3(0, 2, 0) origin: Vec3 = Vec3(0, 0, 0) for j in range(height - 1, -1, -1):
from random import SystemRandom from src.camera import Camera, PositionableCamera from src.hittable import HitRecord, Hittable, HittableList from src.material import Dielectric, Lambertian, Metal, ReflectionRecord from src.ppm import PPM from src.ray import Ray from src.sphere import Sphere from src.utils import _derive_ppm_filename from src.vec3 import Vec3 from typing import List, Optional import math import sys UNIT_VEC3: Vec3 = Vec3(1.0, 1.0, 1.0) random = SystemRandom() def color(ray: Ray, world: HittableList, depth: int) -> Vec3: # Some reflected rays hit not at zero but at some near-zero value due to # floating point shennanigans. So we try to compensate for that. hit_attempt: Optional[HitRecord] = world.hit(ray, 0.001, sys.float_info.max) if hit_attempt is not None: scattering: ReflectionRecord = hit_attempt.material.scatter(ray, hit_attempt) # FIXME Is it really worthwhile to check if reflection is not None here? # All our Materials assume that a hit has been made, and therefore some # reflection should happen (unless it is Vanta). if depth < 50 and scattering is not None: # Compare this with the hard-coded reflection in 6_matterial. return scattering.attenuation * color(scattering.scattering, world, depth + 1) else: return Vec3(0, 0, 0)
from src.ppm import PPM from src.utils import _derive_ppm_filename from src.vec3 import Vec3 if __name__ == "__main__": hello_world = PPM(300, 200) for ri in range(hello_world.height): for ci in range(hello_world.width): v = Vec3(ri / hello_world.width, ci / hello_world.height, 0.2) color = v * 255.99 color.map(int) hello_world.set_pixel(ri, ci, color) hello_world.write(_derive_ppm_filename())
def test_itruediv(self): a = Vec3(3, 6, 9) lowest_terms_a = Vec3(1, 2, 3) a /= 3 self.assertEqual(lowest_terms_a, a)
from random import SystemRandom from src.camera import Camera from src.hittable import HitRecord, Hittable, HittableList from src.material import Lambertian, Metal, ReflectionRecord from src.ppm import PPM from src.ray import Ray from src.sphere import Sphere from src.utils import _derive_ppm_filename from src.vec3 import Vec3 from typing import List, Optional import math import sys UNIT_VEC3: Vec3 = Vec3(1.0, 1.0, 1.0) random = SystemRandom() def color(ray: Ray, world: HittableList, depth: int) -> Vec3: # Some reflected rays hit not at zero but at some near-zero value due to # floating point shennanigans. So we try to compensate for that. hit_attempt: Optional[HitRecord] = world.hit(ray, 0.001, sys.float_info.max) if hit_attempt is not None: reflection: ReflectionRecord = hit_attempt.material.scatter( ray, hit_attempt) # FIXME Is it really worthwhile to check if reflection is not None here? # All our Materials assume that a hit has been made, and therefore some # reflection should happen (unless it is Vanta). if depth < 50 and reflection is not None: # Compare this with the hard-coded reflection in 6_matterial.
colors = (hits.normal.normalized() + Vec3(data=(1., 1., 1.))) * .5 t = 0.5 * (r.direction.normalized().y() + 1.0) sky = Vec3(data=(1., 1., 1.)) * (1. - t) + Vec3(data=(0.5, 0.7, 1.0)) * t return vec_where(hits.t < MAX_VAL, colors, sky) if __name__ == '__main__': start = time.time() # Image image_height = int(IMAGE_WIDTH / ASPECT_RATIO) out_shape = (image_height, IMAGE_WIDTH, 3) data = np.zeros(shape=out_shape) # World world = HittableList() world.add(Sphere(Vec3(data=(0., 0., -1.)), 0.5)) world.add(Sphere(Vec3(data=(0., -100.5, -1.)), 100)) # Camera viewport_width = ASPECT_RATIO * VIEWPORT_HEIGHT focal_length = 1 origin = Vec3(data=(0., 0., 0.)) horizontal = Vec3(data=(viewport_width, 0., 0.)) vertical = Vec3(data=(0., VIEWPORT_HEIGHT, 0.)) lower_left_corner = origin - horizontal / 2 - vertical / 2 x = np.tile( np.linspace(lower_left_corner.x(), lower_left_corner.x() + viewport_width, IMAGE_WIDTH), image_height) y = np.repeat(
from src.camera import Camera, PositionableCamera from src.hittable import HitRecord, Hittable, HittableList from src.material import Lambertian, ReflectionRecord from src.ppm import PPM from src.ray import Ray from src.sphere import Sphere from src.utils import _derive_ppm_filename from src.vec3 import Vec3 from typing import List, Optional import math import random import sys UNIT_VEC3: Vec3 = Vec3(1.0, 1.0, 1.0) def color(ray: Ray, world: HittableList, depth: int) -> Vec3: # Some reflected rays hit not at zero but at some near-zero value due to # floating point shennanigans. So we try to compensate for that. hit_attempt: Optional[HitRecord] = world.hit(ray, 0.001, sys.float_info.max) if hit_attempt is not None: reflection: ReflectionRecord = hit_attempt.material.scatter( ray, hit_attempt) # FIXME Is it really worthwhile to check if reflection is not None here? # All our Materials assume that a hit has been made, and therefore some # reflection should happen (unless it is Vanta). if depth < 50 and reflection is not None: # Compare this with the hard-coded reflection in 6_matterial. return reflection.attenuation * color(reflection.scattering, world,
def ray_color(r, world): hits = world.hit(r, 0, MAX_VAL) colors = (hits.normal.normalized() + Vec3(data=(1., 1., 1.))) * .5 t = 0.5 * (r.direction.normalized().y() + 1.0) sky = Vec3(data=(1., 1., 1.)) * (1. - t) + Vec3(data=(0.5, 0.7, 1.0)) * t return vec_where(hits.t < MAX_VAL, colors, sky)