def test_ray_position(): r = Ray(Point(2, 3, 4), Vector(1, 0, 0)) assert r.position(0) == Point(2, 3, 4) assert r.position(1) == Point(3, 3, 4) assert r.position(-1) == Point(1, 3, 4) assert r.position(2.5) == Point(4.5, 3, 4)
def test_ray_intersect_sphere_tangent(): r = Ray(Point(0, 1, -5), Vector(0, 0, 1)) s = Sphere() x = r.intersects(s) assert len(x) == 2 assert x[0].t == 5 assert x[1].t == 5
def test_ray_intersect_from_behind(): r = Ray(Point(0, 0, 5), Vector(0, 0, 1)) s = Sphere() x = r.intersects(s) assert len(x) == 2 assert x[0].t == -6 assert x[1].t == -4
def test_ray_intersect_from_middle(): r = Ray(Point(0, 0, 0), Vector(0, 0, 1)) s = Sphere() x = r.intersects(s) assert len(x) == 2 assert x[0].t == -1 assert x[1].t == 1
def test_ray_intersects_sphere_object(): r = Ray(Point(0, 0, -5), Vector(0, 0, 1)) s = Sphere() x = r.intersects(s) assert len(x) == 2 assert x[0].object == s assert x[1].object == s
def test_sphere_intersection_scaled(): r = Ray(Point(0, 0, -5), Vector(0, 0, 1)) s = Sphere() s.set_transform(scaling(2, 2, 2)) x = r.intersects(s) assert len(x) == 2 assert x[0].t == 3 assert x[1].t == 7
def hit(self, ray: Ray, t_min: float, t_max: float) -> Optional[HitRecord]: origin_to_center: Vec3 = ray.origin - self.center # The following are just "components" of the quadratic formula, derived # from vectors and with some redundant 2's canceled out to begin with. a: float = ray.direction.dot(ray.direction) b: float = origin_to_center.dot(ray.direction) c: float = origin_to_center.dot(origin_to_center) - (self.radius**2) discriminant: float = (b**2) - (a * c) if discriminant > 0: neg_conjugate: float = (-b - math.sqrt(discriminant)) / a # The original C++ code, again, saves on stack space by re-using a # generic variable `temp`. But heh, I figured can't I skimp a bit on # the manual optimization and leave that up to the interpeter? pos_conjugate: float = (-b + math.sqrt(discriminant)) / a # Also this code was "refactored" from the original C++ to read more # Pythonic. chosen_conjugate = self.__decide_conjugate(t_min, t_max, neg_conjugate, pos_conjugate) if chosen_conjugate is not None: t = chosen_conjugate p = ray.point_at_parameter(t) normal = (p - self.center) / self.radius return HitRecord(t, p, normal, self.material) return None
def test_ray_init(): origin = Point(1, 2, 3) direct = Vector(4, 5, 6) r = Ray(origin, direct) assert r.origin == origin assert r.direction == direct
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 scatter(self, incident_ray: Ray, record: "HitRecord") -> ReflectionRecord: reflected: Vec3 = reflect(incident_ray.direction.unit_vector(), record.normal) scattered: Ray = Ray( record.p, reflected + self.fuzz * random_unit_sphere_point()) return ReflectionRecord(self.albedo, scattered)
def scatter(self, incident_ray: Ray, record: "HitRecord") -> ReflectionRecord: # Lots of Physics I don't understand :\ target: Vec3 = record.p + record.normal + random_unit_sphere_point() scattered: Ray = Ray(record.p, target - record.p) attenuation: Vec3 = self.albedo reflecord: ReflectionRecord = ReflectionRecord(attenuation, scattered) return reflecord
def get_ray(self, s: float, t: float) -> Ray: random_point_in_disc: Vec3 = random_in_unit_disk() * self.lens_radius offset: Vec3 = ( self.__u * random_point_in_disc.x + self.__v * random_point_in_disc.y ) return Ray( self.origin + offset, self.lower_left_corner + (self.h_movement * s) + (self.v_movement * t) - self.origin - offset )
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))
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 test_sphere_intersection_translate(): r = Ray(Point(0, 0, -5), Vector(0, 0, 1)) s = Sphere() s.set_transform(translation(5, 0, 0)) x = r.intersects(s) assert len(x) == 0
def test_scale(): r = Ray(Point(1, 2, 3), Vector(0, 1, 0)) m = scaling(2, 3, 4) r2 = r.transform(m) assert r2.origin == Point(2, 6, 12) assert r2.direction == Vector(0, 3, 0)
def test_translate(): r = Ray(Point(1, 2, 3), Vector(0, 1, 0)) m = translation(3, 4, 5) r2 = r.transform(m) assert r2.origin == Point(4, 6, 8) assert r2.direction == Vector(0, 1, 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( np.linspace(lower_left_corner.y(), lower_left_corner.y() + VIEWPORT_HEIGHT, image_height), IMAGE_WIDTH) colors = [] for i in range(NUM_SAMPLES): x_fidget = np.random.rand( IMAGE_WIDTH * image_height) / (IMAGE_WIDTH - 1) y_fidget = np.random.rand( IMAGE_WIDTH * image_height) / (image_height - 1) uv = Vec3(data=(x + x_fidget, y + y_fidget, -focal_length)) r = Ray(origin, uv - origin) colors.append(ray_color(r, world)) for y in range(image_height): for x in range(IMAGE_WIDTH): u = x / IMAGE_WIDTH v = (image_height - y) / image_height write_color(data, y, x, colors, NUM_SAMPLES) matplotlib.image.imsave('out.png', data) print(f"Finished in {time.time() - start} seconds")
from src.canvas import Canvas from src.color import Color from src.primitives import Sphere from src.ray import Ray from src.transformations import scaling, rotation_z from src.tupl import Point s = Sphere() t = rotation_z(pi / 4) @ scaling(0.5, 1, 1) s.set_transform(t) r_origin = Point(0, 0, -5) wall_z = 10 wall_size = 7 N = 100 c = Canvas(N, N) pixel_size = wall_size / N half = wall_size / 2 red = Color(255, 0, 0) for y in range(c.height): world_y = half - pixel_size * y for x in range(c.width): world_x = -half + pixel_size * x position = Point(world_x, world_y, wall_z) r = Ray(r_origin, (position - r_origin).normalize()) X = r.intersects(s) if X.hit is not None: c.write_pixel(x, y, red) c.to_ppm('circle.ppm')
def get_ray(self, u: float, v: float) -> Ray: return Ray( self.origin, self.lower_left_corner + (self.h_movement * u) + (self.v_movement * v) - self.origin )
def test_ray_intersect_sphere_no_intersection(): r = Ray(Point(0, 2, -5), Vector(0, 0, 1)) s = Sphere() x = r.intersects(s) assert len(x) == 0
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): for i in range(width): # Get the ratio of how far are we from the "edges". u = i / width v = j / height # And use those ratios to "move" the ray away from the origin. Note: # - (h_ * u) + (v_ * v) is scaled movement # - the movement scales differently depending on dimension: # horizontal movement is increasing towards the vector (4, 0, 0) # while vertical movement is decreasing from the vector (0, 2, 0) r: Ray = Ray( origin, lower_left_corner + (h_movement * u) + (v_movement * v)) _color: Vec3 = color(r) _color *= 255.9 _color.map(int) # Note the translation for the row: we want the white part of the # gradient at the bottom so we do this. ppm.set_pixel((height - 1) - j, i, _color) ppm.write(_derive_ppm_filename())
s = Sphere() r_origin = Point(0, 0, -5) wall_z = 10 wall_size = 7 light_position = Point(-10, 10, 10) light_color = Color(1, 1, 1) light = PointLight(light_position, light_color) N = 200 c = Canvas(N, N) pixel_size = wall_size / N half = wall_size / 2 for y in range(c.height): world_y = half - pixel_size * y for x in range(c.width): world_x = -half + pixel_size * x position = Point(world_x, world_y, wall_z) r = Ray(r_origin, (position - r_origin).normalize()) X = r.intersects(s) if X.hit is not None: point = r.position(X.hit.t) normal = X.hit.object.normal_at(point) eye = -r.direction color = X.hit.object.material.lighting(light, point, eye, normal) c.write_pixel(x, y, color) c.to_ppm('circle_shaded.ppm')