ra 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/ra/logger.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A logger used to make exe testing possible.
5
+ #
6
+ # logger = Logger.new
7
+ # logger.log("Greetings!")
8
+ class Logger
9
+ # @param stream [IO]
10
+ def initialize(stream: $stdout)
11
+ @stream = stream
12
+ end
13
+
14
+ # @param message [String]
15
+ def log(message = nil)
16
+ @stream.puts message
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A material is used to define the properties of an object that impact the color applied. For example:
5
+ #
6
+ # material = Ra::Material.new(
7
+ # base: Ra::Color.uniform(0.5),
8
+ # ambient: 0.2,
9
+ # diffuse: 0.5,
10
+ # specular: 0.7,
11
+ # shininess: 200,
12
+ # )
13
+ class Material
14
+ attr_accessor :base, :ambient, :diffuse, :specular, :shininess
15
+
16
+ # @param base [Ra::Color, Ra::Pattern:::Base]
17
+ # @param ambient [Float] between 0.0 and 1.0
18
+ # @param diffuse [Float] between 0.0 and 1.0
19
+ # @param specular [Float] between 0.0 and 1.0
20
+ # @param shininess [Numeric]
21
+ def initialize(base:, ambient: 0.0, diffuse: 0.8, specular: 0.2, shininess: 80)
22
+ @base = base
23
+ @ambient = ambient
24
+ @diffuse = diffuse
25
+ @specular = specular
26
+ @shininess = shininess
27
+ end
28
+
29
+ # @param point [Vector]
30
+ # @return [Ra::Color]
31
+ def color(point:)
32
+ return base if base.is_a?(Color)
33
+
34
+ base.color(point:)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Pattern
5
+ # An abstract pattern. Any concrete subclass of pattern must implement the method `color`.
6
+ class Base
7
+ # @param point [Vector] <u = 0.0..1.0, v = 0.0..1.0>
8
+ # @return [Ra::Color]
9
+ def color(point:)
10
+ raise NotImplementedError, '#color must be implemented by a concrete subclass'
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Pattern
5
+ # A checkers pattern that alternates colors using:
6
+ #
7
+ # colors[⌊u * rows⌋ + ⌊v * cols)⌋ % colors.count]
8
+ class Checkers < Base
9
+ DEFAULT_ROWS = 2
10
+ DEFAULT_COLS = 2
11
+ DEFAULT_COLORS = [
12
+ Color.black,
13
+ Color.white,
14
+ ].freeze
15
+
16
+ # @param rows [Integer]
17
+ # @param cols [Integer]
18
+ # @param colors [Array<Ra::Color>]
19
+ def initialize(cols: DEFAULT_COLS, rows: DEFAULT_ROWS, colors: DEFAULT_COLORS)
20
+ super()
21
+ @rows = rows
22
+ @cols = cols
23
+ @colors = colors
24
+ end
25
+
26
+ # @param point [Vector] <u = 0.0..1.0, v = 0.0..1.0>
27
+ # @return [Ra::Color]
28
+ def color(point:)
29
+ u = point[0]
30
+ v = point[1]
31
+ index = (u * @rows).floor + (v * @cols).floor
32
+
33
+ @colors[index % @colors.count]
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Pattern
5
+ # A graident pattern from `color_a` to `color_b` using:
6
+ #
7
+ # color_b + (color_b - color_a) * (u + v) / 2
8
+ class Gradient < Base
9
+ # @param color_a [Ra::Color]
10
+ # @param color_b [Ra::Color]
11
+ def initialize(color_a:, color_b:)
12
+ super()
13
+ @color_a = color_a
14
+ @color_b = color_b
15
+ end
16
+
17
+ # @param point [Vector] <u = 0.0..1.0, v = 0.0..1.0>
18
+ # @return [Ra::Color]
19
+ def color(point:)
20
+ u = point[0]
21
+ v = point[1]
22
+ value = (u + v) / 2
23
+
24
+ @color_a + ((@color_b - @color_a) * value)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Pattern
5
+ # A rings pattern that alternates colors using:
6
+ #
7
+ # colors[⌊√(u² + v²)⌋]
8
+ class Rings < Base
9
+ # @param colors [Array<Ra::Color>]
10
+ def initialize(colors:)
11
+ super()
12
+ @colors = colors
13
+ end
14
+
15
+ # @param point [Vector] <u = 0.0..1.0, v = 0.0..1.0>
16
+ # @return [Ra::Color]
17
+ def color(point:)
18
+ u = point[0]
19
+ v = point[1]
20
+ index = Math.sqrt((u**2) + (v**2)).floor
21
+
22
+ @colors[index % @colors.count]
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Pattern
5
+ # A stripe pattern that alternates colors using:
6
+ #
7
+ # colors[⌊point.x⌋]
8
+ class Stripes < Base
9
+ # @param colors [Array<Ra::Color>]
10
+ def initialize(colors:)
11
+ super()
12
+ @colors = colors
13
+ end
14
+
15
+ # @param point [Vector] <u = 0.0..1.0, v = 0.0..1.0>
16
+ # @return [Ra::Color]
17
+ def color(point:)
18
+ count = @colors.count
19
+ u = point[0]
20
+ v = point[1]
21
+ value = (u + v) * (2 * count)
22
+
23
+ @colors[value.floor % count]
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mini_magick'
4
+
5
+ module Ra
6
+ module Pattern
7
+ # A texture that can load an AVIF / JPG / PNG / BMP:
8
+ #
9
+ # colors[⌊point.x⌋]
10
+ class Texture < Base
11
+ # @param path [Pathname]
12
+ def initialize(path:)
13
+ super()
14
+ @path = path
15
+ end
16
+
17
+ # @param point [Array<Numeric,Numeric>] <u = 0.0..1.0, v = 0.0..1.0>
18
+ # @return [Ra::Color]
19
+ def color(point:)
20
+ pixel = pixel(point:)
21
+
22
+ Color.new(
23
+ r: Float(pixel[0]) / Color::PRECISION,
24
+ g: Float(pixel[1]) / Color::PRECISION,
25
+ b: Float(pixel[2]) / Color::PRECISION,
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ # @return [Array<0..255,0..255,0..255>]
32
+ def pixel(point:)
33
+ u = point[0]
34
+ v = 1.0 - point[1]
35
+
36
+ x = (u * image.width.pred).round
37
+ y = (v * image.height.pred).round
38
+
39
+ pixels[y][x]
40
+ end
41
+
42
+ # @return [Array<Array<0..255,0..255,0.255>>]
43
+ def pixels
44
+ @pixels ||= image.get_pixels
45
+ end
46
+
47
+ def image
48
+ @image ||= MiniMagick::Image.open(@path)
49
+ end
50
+ end
51
+ end
52
+ end
data/lib/ra/ray.rb ADDED
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A ray is positioned at an origin travelling by a direction. A ray is cast and used to identify collisions with
5
+ # objects. For example:
6
+ #
7
+ # ray = Ra::Ray.new(
8
+ # origin: Vector[0, 0, 0, Ra::Tuple::POINT],
9
+ # direction: Vector[1, 2, 3, Ra::Tuple::VECTOR],
10
+ # )
11
+ # ray.position(t: 1) == Vector[1, 2, 3, Ra::Tuple::VECTOR]
12
+ # ray.position(t: 2) == Vector[2, 4, 6, Ra::Tuple::VECTOR]
13
+ # ray.position(t: 3) == Vector[3, 6, 9, Ra::Tuple::VECTOR]
14
+ #
15
+ # A ray can be transformed. This is useful when considering the ray relative to an object that has a transform
16
+ # associated with it. For example:
17
+ #
18
+ # ray = Ra::Ray.new(
19
+ # origin: Vector[0, 0, 0, Ra::Tuple::POINT],
20
+ # direction: Vector[1, 2, 3, Ra::Tuple::VECTOR],
21
+ # )
22
+ # ray.transform(transform: Ra::Transform.scale(1, 2, 3))
23
+ class Ray
24
+ attr_accessor :origin, :direction
25
+
26
+ # @param origin [Vector] e.g. Vector[1, 2, 3, Ra::Tuple::POINT]
27
+ # @param direction [Vector] e.g. Vector[1, 2, 3, Ra::Tuple::VECTOR]
28
+ def initialize(origin:, direction:)
29
+ @origin = origin
30
+ @direction = direction
31
+ end
32
+
33
+ # @param t [Numeric]
34
+ # @return [Vector]
35
+ def position(t:)
36
+ @origin + (@direction * t)
37
+ end
38
+
39
+ # @param transform [Ra::Transform]
40
+ # @return [Ra::Ray]
41
+ def transform(transform)
42
+ self.class.new(
43
+ origin: transform * @origin,
44
+ direction: transform * @direction,
45
+ )
46
+ end
47
+
48
+ # @return [Boolean]
49
+ def ==(other)
50
+ origin == other.origin && direction == other.direction
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Shape
5
+ # An abstract shape. Any concrete subclass of shape must implement the
6
+ # methods `l_normal` and `t_intersect`. Both methods use a point / ray
7
+ # with a local transform applied.
8
+ class Base
9
+ attr_accessor :material
10
+
11
+ # @param material [Ra::Material]
12
+ # @param transform [Ra::Matrix]
13
+ def initialize(material:, transform: Transform::IDENTITY)
14
+ @material = material
15
+ @transform = transform
16
+ end
17
+
18
+ # @param ray [Ra::Ray]
19
+ # @return [Array<Ra::Intersection>]
20
+ def intersect(ray:)
21
+ t_intersect(ray: ray.transform(@transform.inverse))
22
+ .map { |t| Ra::Intersection.new(ray:, shape: self, t:) }
23
+ end
24
+
25
+ # @param point [Vector] <x, y, z, Tuple::POINT>
26
+ # @return [Vector] <x, y, z, Tuple::POINT>
27
+ def normal(point:)
28
+ normal = @transform.inverse.transpose * l_normal(point: @transform.inverse * point)
29
+
30
+ Vector[normal[0], normal[1], normal[2], Ra::Tuple::VECTOR].normalize
31
+ end
32
+
33
+ # @param point [Vector] <x, y, z, Tuple::POINT>
34
+ # @return [Color]
35
+ def color(point:)
36
+ @material.color(point: uv_point(point: @transform.inverse * point))
37
+ end
38
+
39
+ # @param point [Vector] <x, y, z, Tuple::POINT>
40
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
41
+ def uv_point(point:)
42
+ raise NotImplementedError, '#uv_point must be implemented by a concrete subclass'
43
+ end
44
+
45
+ # @param ray [Ra::Ray] local
46
+ # @return [Array<Intersection>]
47
+ def t_intersect(ray:)
48
+ raise NotImplementedError, '#t_intersect must be implemented by a concrete subclass'
49
+ end
50
+
51
+ # @param point [Vector] local
52
+ # @return [Vector]
53
+ def l_normal(point:)
54
+ raise NotImplementedError, '#l_normal must be implemented by a concrete subclass'
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Shape
5
+ # A cube centered at <0,0,0> with sides of l=2. A cube surface is defined:
6
+ #
7
+ # x between (-1..+1)
8
+ # y between (-1..+1)
9
+ # z between (-1..+1)
10
+ # x = ±1 OR y = ±1 OR z = ±1
11
+ #
12
+ # A ray `x` / `y` / `z` values at `t` use the `origin` and `direction`:
13
+ #
14
+ # x = origin.x + direction.x * t
15
+ # y = origin.y + direction.y * t
16
+ # z = origin.z + direction.z * t
17
+ #
18
+ # The ray therefore may intersect when:
19
+ #
20
+ # origin.x + direction.x * t = ±1 OR origin.y + direction.y * t = ±1 OR origin.z + direction.z * t = ±1
21
+ #
22
+ # Thus 6 planes can be checked for intersect.
23
+ class Cube < Base
24
+ # @param point [Vector] <x, y, z, Tuple::POINT>
25
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
26
+ def uv_point(point:)
27
+ x = point[0]
28
+ y = point[1]
29
+ z = point[2]
30
+ value = [x, y, z].max_by(&:abs)
31
+
32
+ case value
33
+ when x then x.positive? ? uv_point_r(point:) : uv_point_l(point:)
34
+ when y then y.positive? ? uv_point_u(point:) : uv_point_d(point:)
35
+ else z.positive? ? uv_point_f(point:) : uv_point_b(point:)
36
+ end
37
+ end
38
+
39
+ # @param ray [Ra::Ray] local
40
+ # @return [Array<Numeric>]
41
+ def t_intersect(ray:)
42
+ t_min = t_min(ray:)
43
+ t_max = t_max(ray:)
44
+
45
+ return [] if !t_min || !t_max || t_min > t_max
46
+
47
+ [
48
+ t_min,
49
+ t_max,
50
+ ]
51
+ end
52
+
53
+ # @param point [Vector]
54
+ # @return [Ra::Tuple]
55
+ def l_normal(point:)
56
+ x = point[0].abs
57
+ y = point[1].abs
58
+ z = point[2].abs
59
+
60
+ Vector[
61
+ (is_x = x > y && x > z) ? 1 : 0,
62
+ (is_y = y > x && y > z) ? 1 : 0,
63
+ is_x || is_y ? 0 : 1,
64
+ Ra::Tuple::VECTOR
65
+ ]
66
+ end
67
+
68
+ private
69
+
70
+ # @param ray [Ra::Ray]
71
+ # @return [Integer]
72
+ def t_min(ray:)
73
+ (0..2).map { |i| t_min_max(ray.origin[i], ray.direction[i]).min }.max
74
+ end
75
+
76
+ # @param ray [Ra::Ray]
77
+ # @return [Integer]
78
+ def t_max(ray:)
79
+ (0..2).map { |i| t_min_max(ray.origin[i], ray.direction[i]).max }.min
80
+ end
81
+
82
+ # @param origin [Numeric]
83
+ # @param direction [Numeric]
84
+ # @return [Array<Numeric,Numeric>]
85
+ def t_min_max(origin, direction)
86
+ t_min_numerator = -1 - origin
87
+ t_max_numerator = +1 - origin
88
+
89
+ if direction.abs < EPSILON
90
+ t_min = t_min_numerator * Float::INFINITY
91
+ t_max = t_max_numerator * Float::INFINITY
92
+ else
93
+ t_min = t_min_numerator / direction
94
+ t_max = t_max_numerator / direction
95
+ end
96
+
97
+ t_min < t_max ? [t_min, t_max] : [t_max, t_min]
98
+ end
99
+
100
+ # @param point [Vector] <x, y, z, Tuple::POINT>
101
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
102
+ def uv_point_u(point:)
103
+ Vector[
104
+ ((point[0] + 1) % 2) / 2,
105
+ ((1 - point[2]) % 2) / 2,
106
+ ]
107
+ end
108
+
109
+ # @param point [Vector] <x, y, z, Tuple::POINT>
110
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
111
+ def uv_point_d(point:)
112
+ Vector[
113
+ ((point[0] + 1) % 2) / 2,
114
+ ((point[2] + 1) % 2) / 2,
115
+ ]
116
+ end
117
+
118
+ # @param point [Vector] <x, y, z, Tuple::POINT>
119
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
120
+ def uv_point_l(point:)
121
+ Vector[
122
+ ((point[2] + 1) % 2) / 2,
123
+ ((point[1] + 1) % 2) / 2,
124
+ ]
125
+ end
126
+
127
+ # @param point [Vector] <x, y, z, Tuple::POINT>
128
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
129
+ def uv_point_r(point:)
130
+ Vector[
131
+ ((1 - point[2]) % 2) / 2,
132
+ ((point[1] + 1) % 2) / 2,
133
+ ]
134
+ end
135
+
136
+ # @param point [Vector] <x, y, z, Tuple::POINT>
137
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
138
+ def uv_point_b(point:)
139
+ Vector[
140
+ ((1 - point[0]) % 2) / 2,
141
+ ((point[1] + 1) % 2) / 2,
142
+ ]
143
+ end
144
+
145
+ # @param point [Vector] <x, y, z, Tuple::POINT>
146
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
147
+ def uv_point_f(point:)
148
+ Vector[
149
+ ((point[0] + 1) % 2) / 2,
150
+ ((point[1] + 1) % 2) / 2,
151
+ ]
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Shape
5
+ # A plane for all x / z where y = 0. A plane surface is defined:
6
+ #
7
+ # y = 0
8
+ #
9
+ # A ray `x` / `y` / `z` values at `t` use the `origin` and `direction`:
10
+ #
11
+ # x = origin.x + direction.x * t
12
+ # y = origin.y + direction.y * t
13
+ # z = origin.z + direction.z * t
14
+ #
15
+ # Therefore, a plane has a single intersection at:
16
+ #
17
+ # t = -origin.y / direction.y
18
+ #
19
+ # A direction.y < EPISLON indicates the ray does not intersect the plane.
20
+ class Plane < Base
21
+ # @param point [Vector] <x, y, z, Tuple::POINT>
22
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
23
+ def uv_point(point:)
24
+ Vector[
25
+ point[0] % 1, # u = x % 1
26
+ point[2] % 1, # v = y % 2
27
+ ]
28
+ end
29
+
30
+ # @param ray [Ra::Ray] local
31
+ # @return [Array<Numeric>]
32
+ def t_intersect(ray:)
33
+ origin_y = ray.origin[1]
34
+ direction_y = ray.direction[1]
35
+
36
+ return [] if direction_y.abs < EPSILON
37
+
38
+ [-origin_y / direction_y]
39
+ end
40
+
41
+ # @return [Ra::Tuple]
42
+ def l_normal(*)
43
+ Vector[
44
+ 0,
45
+ 1,
46
+ 0,
47
+ Ra::Tuple::VECTOR
48
+ ]
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ module Shape
5
+ # A sphere at origin <0,0,0> with a radius 1. A sphere surface is defined:
6
+ #
7
+ # x² + y² + z² = radius²
8
+ #
9
+ # A unit radius simplifies further:
10
+ #
11
+ # x² + y² + z² = 1
12
+ #
13
+ # A ray `x` / `y` / `z` values at `t` use the `origin` and `direction`:
14
+ #
15
+ # x = origin.x + direction.x * t
16
+ # y = origin.y + direction.y * t
17
+ # z = origin.z + direction.z * t
18
+ #
19
+ # Substituting `x` / `y` / `z` allows for solving for `t`:
20
+ #
21
+ # 1 = (origin.x + direction.x * t)²
22
+ # + (origin.y + direction.y * t)²
23
+ # + (origin.z + direction.z * t)²
24
+ #
25
+ # Simplifying gives us a quadratic formula with terms defined as:
26
+ #
27
+ # a = direction.x² + direction.y² + direction.z²
28
+ # b = 2 * ((origin.x * direction.x) + (origin.y * direction.y) + (origin.z * direction.z))
29
+ # c = origin.x² + origin.y² + origin.z² - 1
30
+ # discriminant = b² - 4ac
31
+ # t = (-b ± √discriminant) / (2a)
32
+ #
33
+ # A discriminant <0 indicates the ray does not intersect the sphere.
34
+ class Sphere < Base
35
+ # @param point [Vector] <x, y, z, Tuple::POINT>
36
+ # @return [Vector] <u = 0.0..1.0, v = 0.0..1.0>
37
+ def uv_point(point:)
38
+ x = point[0]
39
+ y = point[1]
40
+ z = point[2]
41
+
42
+ radius = Vector[x, y, z].magnitude
43
+ theta = Math.atan2(x, z)
44
+ phi = Math.acos(y / radius)
45
+
46
+ u = 1 - ((theta / (2 * Math::PI)) + 0.5)
47
+ v = 1 - (phi / Math::PI)
48
+
49
+ Vector[u, v]
50
+ end
51
+
52
+ # @param ray [Ra::Ray] local
53
+ # @return [Array<Numeric>]
54
+ def t_intersect(ray:)
55
+ origin = ray.origin - Vector[0, 0, 0, Ra::Tuple::POINT]
56
+ direction = ray.direction
57
+
58
+ quadratic(
59
+ a: direction.dot(direction),
60
+ b: 2 * direction.dot(origin),
61
+ c: origin.dot(origin) - 1,
62
+ )
63
+ end
64
+
65
+ # @param point [Vector]
66
+ # @return [Ra::Tuple]
67
+ def l_normal(point:)
68
+ point - Vector[0, 0, 0, Ra::Tuple::VECTOR]
69
+ end
70
+
71
+ private
72
+
73
+ # (-b ± √(b² - 4ac)) / (2a)
74
+ #
75
+ # @param a [Numeric]
76
+ # @param b [Numeric]
77
+ # @param c [Numeric]
78
+ # @return [Array<Numeric>]
79
+ def quadratic(a:, b:, c:)
80
+ discriminant = (b**2) - (4 * a * c)
81
+ return [] if discriminant.negative?
82
+
83
+ [
84
+ (-b - Math.sqrt(discriminant)) / (2 * a),
85
+ (-b + Math.sqrt(discriminant)) / (2 * a),
86
+ ]
87
+ end
88
+ end
89
+ end
90
+ end
data/lib/ra/surface.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A surface contains everything needed to apply lighting.
5
+ class Surface
6
+ attr_accessor :eyev, :normalv, :shape, :point
7
+
8
+ # @param eyev [Vector]
9
+ # @param normalv [Vector]
10
+ # @param shape [Ra::Shape]
11
+ # @param point [Vector]
12
+ def initialize(eyev:, normalv:, shape:, point:)
13
+ @eyev = eyev
14
+ @normalv = normalv.dot(eyev).negative? ? -normalv : +normalv
15
+ @shape = shape
16
+ @point = point
17
+ end
18
+
19
+ # @return [Vector]
20
+ def hpoint
21
+ @hpoint ||= point + (normalv * EPSILON)
22
+ end
23
+ end
24
+ end