ra 0.1.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +9 -22
- data/exe/ra +130 -0
- data/lib/ra/camera.rb +92 -0
- data/lib/ra/canvas.rb +71 -0
- data/lib/ra/color.rb +158 -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 +96 -0
- data/lib/ra/logger.rb +19 -0
- data/lib/ra/material.rb +37 -0
- data/lib/ra/pattern/base.rb +14 -0
- data/lib/ra/pattern/checkers.rb +37 -0
- data/lib/ra/pattern/gradient.rb +28 -0
- data/lib/ra/pattern/rings.rb +26 -0
- data/lib/ra/pattern/stripes.rb +27 -0
- data/lib/ra/pattern/texture.rb +52 -0
- data/lib/ra/ray.rb +53 -0
- data/lib/ra/shape/base.rb +58 -0
- data/lib/ra/shape/cube.rb +155 -0
- data/lib/ra/shape/plane.rb +52 -0
- data/lib/ra/shape/sphere.rb +90 -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 +52 -0
- data/lib/ra.rb +12 -3
- metadata +92 -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: b56b6d752ce23d6484526702a0702d865bfad49f0209a40a8e597adf7a3c1b13
|
4
|
+
data.tar.gz: e45c8ddc28b1723f9e2a114386a2a0927026e9cf2cb54a38f438f3583d84710c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0b2723e84ab6a64a4036615d298e733714838a754aecd59e0146eef97bd7b3c01374a3a9b4e145e9bb3ac437fe6499b41d819bf3fb79be92494bd2a2629c6f39
|
7
|
+
data.tar.gz: 1f74e78ddc9fbd2c61e231601d08519a8279279418ce8df0e8d6528a183412e09bbbfd138cbb3cd15fcb96979cac30b312520b3694b2c1232d394a1598dcfa57
|
data/README.md
CHANGED
@@ -1,31 +1,18 @@
|
|
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) - arguably the original ray tracer.
|
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 > sample.ppm
|
15
|
+
convert -quality 80 sample.ppm sample.avif
|
16
|
+
```
|
30
17
|
|
31
|
-
|
18
|
+
![Sample](./sample.avif)
|
data/exe/ra
ADDED
@@ -0,0 +1,130 @@
|
|
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
|
+
earth = Ra::Pattern::Texture.new(path: File.join(File.dirname(__FILE__), '..', 'textures/earth.avif'))
|
27
|
+
|
28
|
+
light_l = Ra::Light.new(
|
29
|
+
position: Vector[+5, +7, -9, Ra::Tuple::POINT],
|
30
|
+
intensity: Ra::Color.uniform(0.4),
|
31
|
+
)
|
32
|
+
|
33
|
+
light_r = Ra::Light.new(
|
34
|
+
position: Vector[-5, +7, -9, Ra::Tuple::POINT],
|
35
|
+
intensity: Ra::Color.uniform(0.8),
|
36
|
+
)
|
37
|
+
|
38
|
+
camera = Ra::Camera.new(
|
39
|
+
w: config[:w],
|
40
|
+
h: config[:h],
|
41
|
+
fov: config[:fov] * Math::PI / 180,
|
42
|
+
transform: Ra::Transform.view(
|
43
|
+
from: Vector[0, +1.5, -5.0, Ra::Tuple::POINT],
|
44
|
+
to: Vector[0, 0, +1.0, Ra::Tuple::POINT],
|
45
|
+
up: Vector[0, 1, 0, Ra::Tuple::VECTOR],
|
46
|
+
),
|
47
|
+
)
|
48
|
+
|
49
|
+
floor = Ra::Shape::Plane.new(
|
50
|
+
material: Ra::Material.new(base: Ra::Pattern::Checkers.new(
|
51
|
+
colors: [
|
52
|
+
Ra::Color.hex('#e2e8f0'),
|
53
|
+
Ra::Color.hex('#94a3b8'),
|
54
|
+
],
|
55
|
+
)),
|
56
|
+
)
|
57
|
+
|
58
|
+
wall_l = Ra::Shape::Plane.new(
|
59
|
+
material: Ra::Material.new(base: Ra::Pattern::Stripes.new(
|
60
|
+
colors: [
|
61
|
+
Ra::Color.hex('#94a3b8'),
|
62
|
+
Ra::Color.hex('#475569'),
|
63
|
+
],
|
64
|
+
)),
|
65
|
+
transform: Ra::Transform
|
66
|
+
.translate(0, 0, +5.0)
|
67
|
+
.rotate_y(-Math::PI / 4)
|
68
|
+
.rotate_x(Math::PI / 2),
|
69
|
+
)
|
70
|
+
|
71
|
+
wall_r = Ra::Shape::Plane.new(
|
72
|
+
material: Ra::Material.new(base: Ra::Pattern::Stripes.new(
|
73
|
+
colors: [
|
74
|
+
Ra::Color.hex('#94a3b8'),
|
75
|
+
Ra::Color.hex('#475569'),
|
76
|
+
],
|
77
|
+
)),
|
78
|
+
transform: Ra::Transform
|
79
|
+
.translate(0, 0, +5.0)
|
80
|
+
.rotate_y(Math::PI / 4)
|
81
|
+
.rotate_x(Math::PI / 2),
|
82
|
+
)
|
83
|
+
|
84
|
+
sphere = Ra::Shape::Sphere.new(
|
85
|
+
material: Ra::Material.new(base: earth),
|
86
|
+
transform: Ra::Transform
|
87
|
+
.translate(0, +0.5, -2.0)
|
88
|
+
.rotate_y(Math::PI / 2)
|
89
|
+
.scale(0.5, 0.5, 0.5),
|
90
|
+
)
|
91
|
+
|
92
|
+
cube_l = Ra::Shape::Cube.new(
|
93
|
+
material: Ra::Material.new(base: Ra::Pattern::Gradient.new(
|
94
|
+
color_a: Ra::Color.hex('#f43f5e'),
|
95
|
+
color_b: Ra::Color.hex('#8b5cf6'),
|
96
|
+
)),
|
97
|
+
transform: Ra::Transform
|
98
|
+
.translate(+1.0, +0.3, -1.5)
|
99
|
+
.scale(0.3, 0.3, 0.3),
|
100
|
+
)
|
101
|
+
|
102
|
+
cube_r = Ra::Shape::Cube.new(
|
103
|
+
material: Ra::Material.new(base: Ra::Pattern::Gradient.new(
|
104
|
+
color_a: Ra::Color.hex('#84cc16'),
|
105
|
+
color_b: Ra::Color.hex('#f97316'),
|
106
|
+
)),
|
107
|
+
transform: Ra::Transform
|
108
|
+
.translate(-1.0, +0.3, -1.5)
|
109
|
+
.scale(0.3, 0.3, 0.3),
|
110
|
+
)
|
111
|
+
|
112
|
+
lights = [
|
113
|
+
light_l,
|
114
|
+
light_r,
|
115
|
+
]
|
116
|
+
|
117
|
+
shapes = [
|
118
|
+
floor,
|
119
|
+
wall_l,
|
120
|
+
wall_r,
|
121
|
+
sphere,
|
122
|
+
cube_l,
|
123
|
+
cube_r,
|
124
|
+
].freeze
|
125
|
+
|
126
|
+
world = Ra::World.new(lights:, shapes:)
|
127
|
+
engine = Ra::Engine.new(camera:, world:)
|
128
|
+
canvas = engine.render
|
129
|
+
|
130
|
+
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 x [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,71 @@
|
|
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
|
+
PPM_VERSION = 'P3'
|
20
|
+
|
21
|
+
# @param w [Integer]
|
22
|
+
# @param h [Integer]
|
23
|
+
# @param precision [Integer]
|
24
|
+
def initialize(w:, h:, precision: Color::PRECISION)
|
25
|
+
@w = w
|
26
|
+
@h = h
|
27
|
+
@precision = precision
|
28
|
+
@pixels = Array.new(w) { Array.new(h) }
|
29
|
+
end
|
30
|
+
|
31
|
+
# @param x [Integer]
|
32
|
+
# @param y [Integer]
|
33
|
+
def [](x, y)
|
34
|
+
raise ArgumentError, "x=#{x} cannot be negative" if x.negative?
|
35
|
+
raise ArgumentError, "y=#{y} cannot be negative" if y.negative?
|
36
|
+
raise ArgumentError, "x=#{x} must be < #{@w}" unless x < @w
|
37
|
+
raise ArgumentError, "y=#{y} must be < #{@h}" unless y < @h
|
38
|
+
|
39
|
+
@pixels[x][y] || Color.black
|
40
|
+
end
|
41
|
+
|
42
|
+
# @param x [Integer]
|
43
|
+
# @param y [Integer]
|
44
|
+
# @param color [Ra::Color]
|
45
|
+
def []=(x, y, color)
|
46
|
+
raise ArgumentError, "x=#{x} cannot be negative" if x.negative?
|
47
|
+
raise ArgumentError, "y=#{y} cannot be negative" if y.negative?
|
48
|
+
raise ArgumentError, "x=#{x} must be < #{@w}" unless x < @w
|
49
|
+
raise ArgumentError, "y=#{y} must be < #{@h}" unless y < @h
|
50
|
+
|
51
|
+
@pixels[x][y] = color
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [String]
|
55
|
+
def ppm
|
56
|
+
buffer = String.new(<<~PPM, encoding: 'ascii')
|
57
|
+
#{PPM_VERSION}
|
58
|
+
#{@w} #{@h}
|
59
|
+
#{@precision}
|
60
|
+
PPM
|
61
|
+
|
62
|
+
@h.times do |y|
|
63
|
+
@w.times do |x|
|
64
|
+
buffer << (self[x, y].ppm(precision: @precision)) << "\n"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
buffer
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/ra/color.rb
ADDED
@@ -0,0 +1,158 @@
|
|
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
|
+
PRECISION = 255
|
23
|
+
|
24
|
+
# @param value [Array<Numeric,Numeric,Numeric>]
|
25
|
+
# @return [Ra::Color]
|
26
|
+
def self.[](value)
|
27
|
+
new(
|
28
|
+
r: value[0],
|
29
|
+
g: value[1],
|
30
|
+
b: value[2],
|
31
|
+
)
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param value [String] e.g. "#336699"
|
35
|
+
# @return [Ra::Color]
|
36
|
+
def self.hex(value)
|
37
|
+
r, g, b = value.match(/^#(..)(..)(..)$/).captures.map(&:hex)
|
38
|
+
|
39
|
+
new(
|
40
|
+
r: Float(r) / PRECISION,
|
41
|
+
g: Float(g) / PRECISION,
|
42
|
+
b: Float(b) / PRECISION,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
|
46
|
+
# @param value [Numeric] between 0.0 and 1.0
|
47
|
+
# @return [Ra::Color]
|
48
|
+
def self.uniform(value)
|
49
|
+
new(r: value, g: value, b: value)
|
50
|
+
end
|
51
|
+
|
52
|
+
# @return [Ra::Color]
|
53
|
+
def self.white
|
54
|
+
@white ||= uniform(1.0)
|
55
|
+
end
|
56
|
+
|
57
|
+
# @return [Ra::Color]
|
58
|
+
def self.black
|
59
|
+
@black ||= uniform(0.0)
|
60
|
+
end
|
61
|
+
|
62
|
+
# @param r [Numeric] between 0.0 and 1.0
|
63
|
+
# @param g [Numeric] between 0.0 and 1.0
|
64
|
+
# @param b [Numeric] between 0.0 and 1.0
|
65
|
+
def initialize(r: 0.0, g: 0.0, b: 0.0)
|
66
|
+
@r = r
|
67
|
+
@g = g
|
68
|
+
@b = b
|
69
|
+
end
|
70
|
+
|
71
|
+
# @param precision [Integer]
|
72
|
+
# @return [Integer]
|
73
|
+
def ppm(precision: PRECISION)
|
74
|
+
"#{r_val(precision:)} #{g_val(precision:)} #{b_val(precision:)}"
|
75
|
+
end
|
76
|
+
|
77
|
+
# Combine the r / g / b components (+). If `other` is `nil` return `self`.
|
78
|
+
#
|
79
|
+
# @param other [Ra::Color, nil]
|
80
|
+
# @return [Ra::Color]
|
81
|
+
def +(other)
|
82
|
+
return self if other.nil?
|
83
|
+
|
84
|
+
self.class.new(r: r + other.r, g: g + other.g, b: b + other.b)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Combine the r / g / b components (-). If `other` is `nil` return `self`.
|
88
|
+
#
|
89
|
+
# @param other [Ra::Color, nil]
|
90
|
+
# @return [Ra::Color]
|
91
|
+
def -(other)
|
92
|
+
return self if other.nil?
|
93
|
+
|
94
|
+
self.class.new(r: r - other.r, g: g - other.g, b: b - other.b)
|
95
|
+
end
|
96
|
+
|
97
|
+
# @param other [Ra::Color, Numeric]
|
98
|
+
# @return [Ra::Color]
|
99
|
+
def *(other)
|
100
|
+
is_color = other.is_a?(self.class)
|
101
|
+
other_r = is_color ? other.r : other
|
102
|
+
other_g = is_color ? other.g : other
|
103
|
+
other_b = is_color ? other.b : other
|
104
|
+
|
105
|
+
self.class.new(
|
106
|
+
r: r * other_r,
|
107
|
+
g: g * other_g,
|
108
|
+
b: b * other_b,
|
109
|
+
)
|
110
|
+
end
|
111
|
+
|
112
|
+
# @param other [Ra::Color, Numeric]
|
113
|
+
# @return [Ra::Color]
|
114
|
+
def /(other)
|
115
|
+
is_color = other.is_a?(self.class)
|
116
|
+
other_r = is_color ? other.r : other
|
117
|
+
other_g = is_color ? other.g : other
|
118
|
+
other_b = is_color ? other.b : other
|
119
|
+
|
120
|
+
self.class.new(
|
121
|
+
r: r / other_r,
|
122
|
+
g: g / other_g,
|
123
|
+
b: b / other_b,
|
124
|
+
)
|
125
|
+
end
|
126
|
+
|
127
|
+
# @return [Boolean]
|
128
|
+
def ==(other)
|
129
|
+
r_val == other.r_val && g_val == other.g_val && b_val == other.b_val
|
130
|
+
end
|
131
|
+
|
132
|
+
protected
|
133
|
+
|
134
|
+
# @return [Integer]
|
135
|
+
def r_val(precision: PRECISION)
|
136
|
+
val(value: r, precision:)
|
137
|
+
end
|
138
|
+
|
139
|
+
# @return [Integer]
|
140
|
+
def g_val(precision: PRECISION)
|
141
|
+
val(value: g, precision:)
|
142
|
+
end
|
143
|
+
|
144
|
+
# @return [Integer]
|
145
|
+
def b_val(precision: PRECISION)
|
146
|
+
val(value: b, precision:)
|
147
|
+
end
|
148
|
+
|
149
|
+
private
|
150
|
+
|
151
|
+
# @param value [Numeric]
|
152
|
+
# @param precision [Integer]
|
153
|
+
# @return [Integer]
|
154
|
+
def val(value:, precision: PRECISION)
|
155
|
+
(value * precision).clamp(0, precision).round
|
156
|
+
end
|
157
|
+
end
|
158
|
+
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
|
data/lib/ra/lighting.rb
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Ra
|
4
|
+
# Lighting encaspulates a [Phong Reflection Model](https://en.wikipedia.org/wiki/phong_reflection_model).
|
5
|
+
class Lighting
|
6
|
+
attr_accessor :light, :surface, :shadowed
|
7
|
+
|
8
|
+
# @param light [Ra::Light]
|
9
|
+
# @param surface [Ra::Surface]
|
10
|
+
# @param shadowed [Boolean]
|
11
|
+
def initialize(light:, surface:, shadowed:)
|
12
|
+
@surface = surface
|
13
|
+
@shadowed = shadowed
|
14
|
+
@light = light
|
15
|
+
end
|
16
|
+
|
17
|
+
# @return [Ra::Color]
|
18
|
+
def color
|
19
|
+
ambient_color + diffuse_color + specular_color
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# @return [Ra::Shape]
|
25
|
+
def shape
|
26
|
+
surface.shape
|
27
|
+
end
|
28
|
+
|
29
|
+
# @return [Vector]
|
30
|
+
def point
|
31
|
+
surface.point
|
32
|
+
end
|
33
|
+
|
34
|
+
# @return [Vector]
|
35
|
+
def normalv
|
36
|
+
surface.normalv
|
37
|
+
end
|
38
|
+
|
39
|
+
# @return [Vector]
|
40
|
+
def eyev
|
41
|
+
surface.eyev
|
42
|
+
end
|
43
|
+
|
44
|
+
# @return [Ra::Material]
|
45
|
+
def material
|
46
|
+
shape.material
|
47
|
+
end
|
48
|
+
|
49
|
+
# @return [Ra::Vector]
|
50
|
+
def lightv
|
51
|
+
@lightv ||= (light.position - point).normalize
|
52
|
+
end
|
53
|
+
|
54
|
+
# @return [Ra::Vector]
|
55
|
+
def reflectv
|
56
|
+
@reflectv ||= -(lightv - (normalv * 2 * lightv.dot(normalv)))
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [Ra::Vector]
|
60
|
+
def light_dot_normal
|
61
|
+
@light_dot_normal ||= lightv.dot(normalv)
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [Ra::Vector]
|
65
|
+
def reflect_dot_eye
|
66
|
+
@reflect_dot_eye ||= reflectv.dot(eyev)
|
67
|
+
end
|
68
|
+
|
69
|
+
# @return [Ra::Color]
|
70
|
+
def ambient_color
|
71
|
+
@ambient_color ||= effective_color * material.ambient
|
72
|
+
end
|
73
|
+
|
74
|
+
# @return [Ra::Color, nil]
|
75
|
+
def diffuse_color
|
76
|
+
return if shadowed
|
77
|
+
return if light_dot_normal.negative?
|
78
|
+
|
79
|
+
@diffuse_color ||= effective_color * material.diffuse * light_dot_normal
|
80
|
+
end
|
81
|
+
|
82
|
+
# @return [Ra::Color, nil]
|
83
|
+
def specular_color
|
84
|
+
return if shadowed
|
85
|
+
return if light_dot_normal.negative?
|
86
|
+
return unless reflect_dot_eye.positive?
|
87
|
+
|
88
|
+
@specular_color ||= light.intensity * material.specular * (reflect_dot_eye**material.shininess)
|
89
|
+
end
|
90
|
+
|
91
|
+
# @return [Ra::Color]
|
92
|
+
def effective_color
|
93
|
+
@effective_color ||= shape.color(point:) * light.intensity
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|