ra 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0132d533f2f492c3e1816a06c4e4ff8dbed1be99720685e503d7db1bd1f95169
4
- data.tar.gz: 8a08e49e2fcd0b13369814633f44d7ae8eddd62364b56ff3b7d1dc067fda8d5e
3
+ metadata.gz: 3789cd972787fd603b8b9fb569a1c57ee339d908ccb3b28b27cc66631f9568ef
4
+ data.tar.gz: 748652af8fde85dbeb80fbdfc2cf4fd33756938654522d9f3c2b03f739bcadad
5
5
  SHA512:
6
- metadata.gz: 700954bb31ae48227519b590f50ea0337c05a54896276dc0efcc84010385bd0d5373621c00788b5de8558f97f5cdc679ea48cfd584c45887d0c0175fd52279e4
7
- data.tar.gz: 5fcf6251eff2401b4fde9a47ac4fcd2f05a8b69b81586bd433aa7d4bae52165fbc879ac209fd64bb3dc7bb65d4c350cc9cbecb40e44c3d92ba76abe433216300
6
+ metadata.gz: 862fb845e4379218540100334fb7366f9ecf1674d1026da473ddc6c783bf228b6483512bf51d0c83218ec650ff1959e61a48756953da1a6118aae165c4057f77
7
+ data.tar.gz: 9d21e31c6fda9b0795316ea2a9690fd087d646b5758ab873d82fb2aebec377cd08327a0eefde9cea00d8ff4e1088ddbb015691f7e16baee509b967918b07bcd6
data/README.md CHANGED
@@ -1,31 +1,17 @@
1
1
  # Ra
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/ra`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Named for ["Ra"](https://en.wikipedia.org/wiki/Ra).
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
-
11
- Install the gem and add to the application's Gemfile by executing:
12
-
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
14
-
15
- If bundler is not being used to manage dependencies, install the gem by executing:
16
-
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_PRIOR_TO_RELEASE_TO_RUBYGEMS_ORG
7
+ ```sh
8
+ gem install ra
9
+ ```
18
10
 
19
11
  ## Usage
20
12
 
21
- TODO: Write usage instructions here
22
-
23
- ## Development
24
-
25
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
26
-
27
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
28
-
29
- ## Contributing
13
+ ```sh
14
+ ra -w 2560 -h 2048 | convert - sample.avif
15
+ ```
30
16
 
31
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/ra.
17
+ ![Sample](./sample.avif)
data/exe/ra ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # frozen_string_literal: true
4
+
5
+ require 'ra'
6
+ require 'slop'
7
+
8
+ config = Slop.parse(ARGV) do |options|
9
+ options.banner = 'Usage: ra -w 2560 -h 2048 | convert - sample.avif'
10
+
11
+ options.integer '-w', '--width', 'width', default: 1280
12
+ options.integer '-h', '--height', 'height', default: 1024
13
+ options.integer '-fov', 'degrees', default: 60
14
+
15
+ options.on('--help', 'help') do
16
+ Ra.logger.log(options)
17
+ exit
18
+ end
19
+
20
+ options.on('--version', 'version') do
21
+ Ra.logger.log(Ra::VERSION)
22
+ exit
23
+ end
24
+ end
25
+
26
+ light = Ra::Light.new(
27
+ position: Vector[+5, +7, -9, Ra::Tuple::POINT],
28
+ intensity: Ra::Color.white,
29
+ )
30
+
31
+ camera = Ra::Camera.new(
32
+ w: config[:w],
33
+ h: config[:h],
34
+ fov: config[:fov] * Math::PI / 180,
35
+ transform: Ra::Transform.view(
36
+ from: Vector[0, +1.5, -5.0, Ra::Tuple::POINT],
37
+ to: Vector[0, 0, +1.0, Ra::Tuple::POINT],
38
+ up: Vector[0, 1, 0, Ra::Tuple::VECTOR],
39
+ ),
40
+ )
41
+
42
+ floor_material = Ra::Material.new(base: Ra::Pattern::Checkers.new(
43
+ colors: [
44
+ Ra::Color.hex('#e2e8f0'),
45
+ Ra::Color.hex('#94a3b8'),
46
+ ],
47
+ transform: Ra::Transform
48
+ .scale(0.4, 0.4, 0.4)
49
+ .translate(0, +0.2, 0),
50
+ ))
51
+
52
+ wall_material = Ra::Material.new(base: Ra::Pattern::Stripes.new(
53
+ colors: [
54
+ Ra::Color.hex('#94a3b8'),
55
+ Ra::Color.hex('#475569'),
56
+ ],
57
+ transform: Ra::Transform
58
+ .rotate_x(Math::PI / 4)
59
+ .rotate_y(Math::PI / 4)
60
+ .scale(0.2, 0.2, 0.2),
61
+ ))
62
+
63
+ floor = Ra::Shape::Plane.new(
64
+ material: floor_material,
65
+ )
66
+
67
+ wall_l = Ra::Shape::Plane.new(
68
+ material: wall_material,
69
+ transform: Ra::Transform
70
+ .translate(0, 0, +5.0)
71
+ .rotate_y(-Math::PI / 4)
72
+ .rotate_x(Math::PI / 2),
73
+ )
74
+
75
+ wall_r = Ra::Shape::Plane.new(
76
+ material: wall_material,
77
+ transform: Ra::Transform
78
+ .translate(0, 0, +5.0)
79
+ .rotate_y(Math::PI / 4)
80
+ .rotate_x(Math::PI / 2),
81
+ )
82
+
83
+ sphere = Ra::Shape::Sphere.new(
84
+ material: Ra::Material.new(base: Ra::Pattern::Rings.new(
85
+ colors: [
86
+ Ra::Color.hex('#f87171'),
87
+ Ra::Color.hex('#dc2626'),
88
+ ],
89
+ transform: Ra::Transform
90
+ .rotate_x(Math::PI / 4)
91
+ .rotate_y(Math::PI / 4)
92
+ .scale(0.2, 0.2, 0.2),
93
+ )),
94
+ transform: Ra::Transform
95
+ .translate(0, +0.5, -2.0)
96
+ .scale(0.5, 0.5, 0.5),
97
+ )
98
+
99
+ cube_l = Ra::Shape::Cube.new(
100
+ material: Ra::Material.new(base: Ra::Pattern::Gradient.new(
101
+ color_a: Ra::Color.hex('#f43f5e'),
102
+ color_b: Ra::Color.hex('#8b5cf6'),
103
+ transform: Ra::Transform
104
+ .translate(1.0, 1.0, 1.0)
105
+ .scale(3.0, 3.0, 3.0),
106
+ )),
107
+ transform: Ra::Transform
108
+ .translate(+1.0, +0.3, -1.5)
109
+ .scale(0.3, 0.3, 0.3),
110
+ )
111
+
112
+ cube_r = Ra::Shape::Cube.new(
113
+ material: Ra::Material.new(base: Ra::Pattern::Gradient.new(
114
+ color_a: Ra::Color.hex('#84cc16'),
115
+ color_b: Ra::Color.hex('#f97316'),
116
+ transform: Ra::Transform
117
+ .translate(1.0, 1.0, 1.0)
118
+ .scale(3.0, 3.0, 3.0),
119
+ )),
120
+ transform: Ra::Transform
121
+ .translate(-1.0, -0.3, -1.5)
122
+ .scale(0.3, 0.3, 0.3),
123
+ )
124
+
125
+ shapes = [
126
+ floor,
127
+ wall_l,
128
+ wall_r,
129
+ sphere,
130
+ cube_l,
131
+ cube_r,
132
+ ].freeze
133
+
134
+ world = Ra::World.new(light:, shapes:)
135
+ engine = Ra::Engine.new(camera:, world:)
136
+ canvas = engine.render
137
+
138
+ Ra.logger.log(canvas.ppm)
data/lib/ra/camera.rb ADDED
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A camera produces rays used to generate pixels. By convention, the camera
5
+ # is positioned at the point <x=0,y=0,z=0>. The rays target an imaginary
6
+ # screen that exists at z=-1. The x,y values visible on the screen depend on
7
+ # the FOV and the width / height of the desired image.
8
+ #
9
+ # A FOV represents the angel of the world visible to the camera. A default
10
+ # FOV is 90 degrees. This results in 2.0 by 2.0 of the world visible at z=-1.
11
+ #
12
+ # A bigger FOV increases what in the world is visible to the camera. When FOV
13
+ # is 120 degrees then ~3.5 by ~3.5 world view visible through z=-1.
14
+ #
15
+ # A smaller FOV decreases what in the world is visible to the camera. When FOV
16
+ # is 60 degrees then ~1.2 by ~1.2 world view is visible through z=-1.
17
+ #
18
+ # The visible world view is then split into pixels bsaed on the l / w of the
19
+ # desired screen. The pixel size is calculated using these l / w dimensions.
20
+ # The pixels are defined to be evenly spaced within the visible world.
21
+ #
22
+ # An example of a default 90 degree FOV and w=5 / h=4 results in pixels that
23
+ # are of size 0.4 (the greater of 2.0 / w=5 and 2.0 / h=4). With these
24
+ # dimensions rays are cast to the center of pixels evenly distrubted across
25
+ # the screen.
26
+ class Camera
27
+ attr_accessor :h, :w, :fov, :transform
28
+
29
+ DEFAULT_W = 1280
30
+ DEFAULT_H = 1024
31
+ DEFAULT_FOV = Math::PI / 3
32
+
33
+ # @param transform [Ra::Transform]
34
+ # @param h [Numeric]
35
+ # @param w [Numeric]
36
+ # @param fov [Numeric]
37
+ def initialize(transform: Transform::IDENTITY, h: DEFAULT_H, w: DEFAULT_W, fov: DEFAULT_FOV)
38
+ @transform = transform
39
+ @h = h
40
+ @w = w
41
+ @fov = fov
42
+ end
43
+
44
+ # @param x [Numeric]
45
+ # @param y [Numeric]
46
+ # @return [Ra::Ray]
47
+ def ray(x:, y:)
48
+ pixel = transform.inverse * Vector[world_x(x:), world_y(y:), -1, Tuple::POINT]
49
+ origin = transform.inverse * Vector[0, 0, 0, Tuple::POINT]
50
+
51
+ direction = (pixel - origin).normalize
52
+
53
+ Ray.new(origin:, direction:)
54
+ end
55
+
56
+ # @return [Float]
57
+ def p_size
58
+ @p_size ||= half_w * 2 / w
59
+ end
60
+
61
+ # @return [Float]
62
+ def half_view
63
+ @half_view ||= Math.tan(@fov / 2)
64
+ end
65
+
66
+ # @return [Float]
67
+ def half_w
68
+ @half_w ||= @w < @h ? (half_view * @w / @h) : half_view
69
+ end
70
+
71
+ # @return [Float]
72
+ def half_h
73
+ @half_h ||= @h < @w ? (half_view * @h / @w) : half_view
74
+ end
75
+
76
+ private
77
+
78
+ # @param y [Float]
79
+ # @return [Float]
80
+ def world_y(y:)
81
+ offset_y = (y + 0.5) * p_size
82
+ half_h - offset_y
83
+ end
84
+
85
+ # @param y [Float]
86
+ # @return [Float]
87
+ def world_x(x:)
88
+ offset_x = (x + 0.5) * p_size
89
+ half_w - offset_x
90
+ end
91
+ end
92
+ end
data/lib/ra/canvas.rb ADDED
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A canvas is used to track pixels at <x,y> coordinates. It is given a w / h
5
+ # and on initialization allocates a w by h collection of pixels. For example,
6
+ # defining a basic black and white checkboard canvas with altering squares
7
+ # then saving as [PPM](https://netpbm.sourceforge.net/doc/ppm.html):
8
+ #
9
+ # canvas = Ra::Canvas.new(w: 4, h: 5, precision: 15)
10
+ # canvas.w.times do |x|
11
+ # canvas.h.times do |y|
12
+ # canvas[x,y] = (x + y) % 2 == 0 ? Ra::Color.black : Ra::Color.white
13
+ # end
14
+ # end
15
+ # canvas.ppm
16
+ class Canvas
17
+ attr_accessor :w, :h, :precision
18
+
19
+ DEFAULT_COLOR = Color.black
20
+ private_constant :DEFAULT_COLOR
21
+
22
+ DEFAULT_PRECISION = 255
23
+ private_constant :DEFAULT_PRECISION
24
+
25
+ PPM_VERSION = 'P3'
26
+ private_constant :PPM_VERSION
27
+
28
+ # @param w [Integer]
29
+ # @param h [Integer]
30
+ # @param precision [Integer]
31
+ def initialize(w:, h:, precision: DEFAULT_PRECISION)
32
+ @w = w
33
+ @h = h
34
+ @precision = precision
35
+ @pixels = Array.new(w) { Array.new(h) }
36
+ end
37
+
38
+ # @param x [Integer]
39
+ # @param y [Integer]
40
+ def [](x, y)
41
+ raise ArgumentError, "x=#{x} cannot be negative" if x.negative?
42
+ raise ArgumentError, "y=#{y} cannot be negative" if y.negative?
43
+ raise ArgumentError, "x=#{x} must be < #{@w}" unless x < @w
44
+ raise ArgumentError, "y=#{y} must be < #{@h}" unless y < @h
45
+
46
+ @pixels[x][y] || DEFAULT_COLOR
47
+ end
48
+
49
+ # @param x [Integer]
50
+ # @param y [Integer]
51
+ # @param color [Ra::Color]
52
+ def []=(x, y, color)
53
+ raise ArgumentError, "x=#{x} cannot be negative" if x.negative?
54
+ raise ArgumentError, "y=#{y} cannot be negative" if y.negative?
55
+ raise ArgumentError, "x=#{x} must be < #{@w}" unless x < @w
56
+ raise ArgumentError, "y=#{y} must be < #{@h}" unless y < @h
57
+
58
+ @pixels[x][y] = color
59
+ end
60
+
61
+ # @return [String]
62
+ def ppm
63
+ buffer = String.new(<<~PPM, encoding: 'ascii')
64
+ #{PPM_VERSION}
65
+ #{@w} #{@h}
66
+ #{@precision}
67
+ PPM
68
+
69
+ @h.times do |y|
70
+ @w.times do |x|
71
+ buffer << (self[x, y].ppm(precision: @precision)) << "\n"
72
+ end
73
+ end
74
+
75
+ buffer
76
+ end
77
+ end
78
+ end
data/lib/ra/color.rb ADDED
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # Color can be represented using r / g / b. Each component is assigned a
5
+ # number between 0.0 and 1.0. A color can also be converted for use when
6
+ # saving as [PPM](https://netpbm.sourceforge.net/doc/ppm.html).
7
+ #
8
+ # color = Ra::Color.hex("#00FF00")
9
+ # color.r == 0.0
10
+ # color.g == 1.0
11
+ # color.b == 0.0
12
+ #
13
+ # color = Ra::Color.new(
14
+ # r: 0.5,
15
+ # g: 0.7,
16
+ # b: 0.9,
17
+ # )
18
+ # color.ppm == "128 179 230"
19
+ class Color
20
+ attr_accessor :r, :g, :b
21
+
22
+ DEFAULT_PRECISION = 255
23
+ private_constant :DEFAULT_PRECISION
24
+
25
+ # @param value [String] e.g. "#336699"
26
+ # @return [Ra::Color]
27
+ def self.hex(value)
28
+ r, g, b = value.match(/^#(..)(..)(..)$/).captures.map(&:hex)
29
+
30
+ new(
31
+ r: Float(r) / DEFAULT_PRECISION,
32
+ g: Float(g) / DEFAULT_PRECISION,
33
+ b: Float(b) / DEFAULT_PRECISION,
34
+ )
35
+ end
36
+
37
+ # @param value [Numeric] between 0.0 and 1.0
38
+ # @return [Ra::Color]
39
+ def self.uniform(value)
40
+ new(r: value, g: value, b: value)
41
+ end
42
+
43
+ # @return [Ra::Color]
44
+ def self.white
45
+ @white ||= uniform(1.0)
46
+ end
47
+
48
+ # @return [Ra::Color]
49
+ def self.black
50
+ @black ||= uniform(0.0)
51
+ end
52
+
53
+ # @param r [Numeric] between 0.0 and 1.0
54
+ # @param g [Numeric] between 0.0 and 1.0
55
+ # @param b [Numeric] between 0.0 and 1.0
56
+ def initialize(r: 0.0, g: 0.0, b: 0.0)
57
+ @r = r
58
+ @g = g
59
+ @b = b
60
+ end
61
+
62
+ # @param precision [Integer]
63
+ # @return [Integer]
64
+ def ppm(precision: DEFAULT_PRECISION)
65
+ "#{r_val(precision:)} #{g_val(precision:)} #{b_val(precision:)}"
66
+ end
67
+
68
+ # Combine the r / g / b components (+). If `other` is `nil` return `self`.
69
+ #
70
+ # @param other [Ra::Color, nil]
71
+ # @return [Ra::Color]
72
+ def +(other)
73
+ return self if other.nil?
74
+
75
+ self.class.new(r: r + other.r, g: g + other.g, b: b + other.b)
76
+ end
77
+
78
+ # Combine the r / g / b components (-). If `other` is `nil` return `self`.
79
+ #
80
+ # @param other [Ra::Color, nil]
81
+ # @return [Ra::Color]
82
+ def -(other)
83
+ return self if other.nil?
84
+
85
+ self.class.new(r: r - other.r, g: g - other.g, b: b - other.b)
86
+ end
87
+
88
+ # @param other [Ra::Color, Numeric]
89
+ # @return [Ra::Color]
90
+ def *(other)
91
+ is_color = other.is_a?(self.class)
92
+ other_r = is_color ? other.r : other
93
+ other_g = is_color ? other.g : other
94
+ other_b = is_color ? other.b : other
95
+
96
+ self.class.new(
97
+ r: r * other_r,
98
+ g: g * other_g,
99
+ b: b * other_b,
100
+ )
101
+ end
102
+
103
+ # @param other [Ra::Color, Numeric]
104
+ # @return [Ra::Color]
105
+ def /(other)
106
+ is_color = other.is_a?(self.class)
107
+ other_r = is_color ? other.r : other
108
+ other_g = is_color ? other.g : other
109
+ other_b = is_color ? other.b : other
110
+
111
+ self.class.new(
112
+ r: r / other_r,
113
+ g: g / other_g,
114
+ b: b / other_b,
115
+ )
116
+ end
117
+
118
+ # @return [Boolean]
119
+ def ==(other)
120
+ r_val == other.r_val && g_val == other.g_val && b_val == other.b_val
121
+ end
122
+
123
+ protected
124
+
125
+ # @return [Integer]
126
+ def r_val(precision: DEFAULT_PRECISION)
127
+ val(value: r, precision:)
128
+ end
129
+
130
+ # @return [Integer]
131
+ def g_val(precision: DEFAULT_PRECISION)
132
+ val(value: g, precision:)
133
+ end
134
+
135
+ # @return [Integer]
136
+ def b_val(precision: DEFAULT_PRECISION)
137
+ val(value: b, precision:)
138
+ end
139
+
140
+ private
141
+
142
+ # @param value [Numeric]
143
+ # @param precision [Integer]
144
+ # @return [Integer]
145
+ def val(value:, precision: DEFAULT_PRECISION)
146
+ (value * precision).clamp(0, precision).round
147
+ end
148
+ end
149
+ end
data/lib/ra/engine.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # An engine takes uses a world / camera to generate a canvas.
5
+ class Engine
6
+ PRECISION = 255
7
+
8
+ # @param world [Ra::World]
9
+ # @param camera [Ra::Camera]
10
+ def initialize(world:, camera:)
11
+ @world = world
12
+ @camera = camera
13
+ end
14
+
15
+ # @return [Ra::Canvas]
16
+ def render
17
+ Ra::Canvas.new(w: @camera.w, h: @camera.h, precision: PRECISION).tap do |canvas|
18
+ @camera.h.times do |y|
19
+ @camera.w.times do |x|
20
+ draw(x:, y:, canvas:)
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ # @param x [Integer]
29
+ # @param y [Integer]
30
+ # @param canvas [Ra::Canvas]
31
+ def draw(x:, y:, canvas:)
32
+ ray = @camera.ray(x:, y:)
33
+
34
+ intersections = @world.intersect(ray:)
35
+ intersection = Intersection.hit(intersections:)
36
+
37
+ canvas[x, y] = @world.color(intersection:) if intersection
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # An intersection tracks the time t at which a shape is intersected by a ray.
5
+ class Intersection
6
+ attr_accessor :t, :ray, :shape
7
+
8
+ # @param intersections Array<Ra::Intersection>
9
+ # @return [Ra::Intersection, nil]
10
+ def self.hit(intersections:)
11
+ hit = nil
12
+
13
+ intersections.each do |interaction|
14
+ next if interaction.t.negative?
15
+
16
+ hit = interaction if hit.nil? || interaction.t < hit.t
17
+ end
18
+
19
+ hit
20
+ end
21
+
22
+ # @param t [Numeric]
23
+ # @param ray [Ra::Ray]
24
+ # @param shape [Ra::Shape::Base]
25
+ def initialize(t:, ray:, shape:)
26
+ @t = t
27
+ @ray = ray
28
+ @shape = shape
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def ==(other)
33
+ t == other.t && ray == other.ray && shape == other.shape
34
+ end
35
+
36
+ # @return [Ra::Surface]
37
+ def surface
38
+ point = ray.position(t:)
39
+ eyev = -ray.direction
40
+ normalv = shape.normal(point:)
41
+
42
+ Surface.new(shape:, eyev:, normalv:, point:)
43
+ end
44
+ end
45
+ end
data/lib/ra/light.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ra
4
+ # A light has a position within a scene and an intensity it is rendered
5
+ # with. For example:
6
+ #
7
+ # light = Ra::Light.new(
8
+ # intensity: Ra::Color.uniform(0.8),
9
+ # position: Vector[0, 0, 0, Ra::Tuple::POINT],
10
+ # )
11
+ class Light
12
+ attr_accessor :intensity, :position
13
+
14
+ # @param intensity [Ra::Color]
15
+ # @param position [Vector]
16
+ def initialize(intensity:, position:)
17
+ @intensity = intensity
18
+ @position = position
19
+ end
20
+ end
21
+ end