ra 0.1.0 → 0.2.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.
- checksums.yaml +4 -4
- data/README.md +8 -22
- data/exe/ra +138 -0
- data/lib/ra/camera.rb +92 -0
- data/lib/ra/canvas.rb +78 -0
- data/lib/ra/color.rb +149 -0
- data/lib/ra/engine.rb +40 -0
- data/lib/ra/intersection.rb +45 -0
- data/lib/ra/light.rb +21 -0
- data/lib/ra/lighting.rb +97 -0
- data/lib/ra/logger.rb +19 -0
- data/lib/ra/material.rb +37 -0
- data/lib/ra/pattern/base.rb +31 -0
- data/lib/ra/pattern/checkers.rb +32 -0
- data/lib/ra/pattern/gradient.rb +29 -0
- data/lib/ra/pattern/rings.rb +31 -0
- data/lib/ra/pattern/stripes.rb +30 -0
- data/lib/ra/ray.rb +53 -0
- data/lib/ra/shape/base.rb +54 -0
- data/lib/ra/shape/cube.rb +89 -0
- data/lib/ra/shape/plane.rb +45 -0
- data/lib/ra/shape/sphere.rb +75 -0
- data/lib/ra/surface.rb +24 -0
- data/lib/ra/transform.rb +141 -0
- data/lib/ra/tuple.rb +26 -0
- data/lib/ra/version.rb +1 -1
- data/lib/ra/world.rb +48 -0
- data/lib/ra.rb +12 -3
- metadata +77 -13
- data/.rspec +0 -3
- data/.rubocop.yml +0 -13
- data/Rakefile +0 -12
- data/sig/ra.rbs +0 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3789cd972787fd603b8b9fb569a1c57ee339d908ccb3b28b27cc66631f9568ef
|
4
|
+
data.tar.gz: 748652af8fde85dbeb80fbdfc2cf4fd33756938654522d9f3c2b03f739bcadad
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 862fb845e4379218540100334fb7366f9ecf1674d1026da473ddc6c783bf228b6483512bf51d0c83218ec650ff1959e61a48756953da1a6118aae165c4057f77
|
7
|
+
data.tar.gz: 9d21e31c6fda9b0795316ea2a9690fd087d646b5758ab873d82fb2aebec377cd08327a0eefde9cea00d8ff4e1088ddbb015691f7e16baee509b967918b07bcd6
|
data/README.md
CHANGED
@@ -1,31 +1,17 @@
|
|
1
1
|
# Ra
|
2
2
|
|
3
|
-
|
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
|
-
|
10
|
-
|
11
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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
|
-
|
17
|
+

|
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
|