caustics 0.1.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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +90 -0
- data/Rakefile +9 -0
- data/docs/spikes.md +19 -0
- data/exe/caustics +52 -0
- data/lib/caustics/version.rb +5 -0
- data/lib/caustics.rb +809 -0
- data/scenes/shader.rb +8 -0
- data/scenes/weekend.rb +11 -0
- data/sig/caustics.rbs +4 -0
- metadata +68 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 07a93d859b9558e27661a91aab00238fa15788a739c3c9abba0c45694e65551c
|
|
4
|
+
data.tar.gz: 90105dc64c843e76cbe7cfd6a7cfe73d56313aad8652f93e04fc881cd542f1bc
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4502f7ff407720bf3d647607a0b5d6984ef7efebbd44a13840c6b29869e1b20dd0cb2b9d5307f5f63bea2dac493024bb898021a48999578477e81c14d10b2a15
|
|
7
|
+
data.tar.gz: c012eb9814e5089bb52dd94e102960d170baa4a771072f4912adc69e4f5ef6e3a4d5aedef3ab6ce54381212d9759d4ca4c7379e9eb935ccb6ab08a019400804d
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ydah
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Caustics
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/caustics)
|
|
4
|
+
[](https://rubygems.org/gems/caustics)
|
|
5
|
+
[](https://github.com/rbgfx/caustics/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](LICENSE.txt)
|
|
8
|
+
|
|
9
|
+
> A deterministic pure Ruby path tracer and teaching example.
|
|
10
|
+
|
|
11
|
+
Caustics is a small ray tracer for experimenting with cameras, materials,
|
|
12
|
+
textures, acceleration, and renderer backends. The Ruby engine is the reference
|
|
13
|
+
implementation; optional rlsl and Metal engines cover a deliberately smaller
|
|
14
|
+
shader subset.
|
|
15
|
+
|
|
16
|
+
**[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Engines](#engines) · [Development](#development)**
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Rays, spheres, planes, triangles, quads, and a BVH accelerator.
|
|
21
|
+
- Lambertian, metal, dielectric, emission, checker, image, and Perlin textures.
|
|
22
|
+
- Depth of field, deterministic per-pixel sampling, and an accumulator.
|
|
23
|
+
- Process-parallel rendering where <code>fork</code> is available.
|
|
24
|
+
- Interactive preview with progressive sampling.
|
|
25
|
+
- Ruby, rlsl C, and Metal command-line engines.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
Add Caustics to your Gemfile:
|
|
30
|
+
|
|
31
|
+
~~~ruby
|
|
32
|
+
gem "caustics"
|
|
33
|
+
~~~
|
|
34
|
+
|
|
35
|
+
Then run:
|
|
36
|
+
|
|
37
|
+
~~~sh
|
|
38
|
+
bundle install
|
|
39
|
+
~~~
|
|
40
|
+
|
|
41
|
+
Or install the released gem:
|
|
42
|
+
|
|
43
|
+
~~~sh
|
|
44
|
+
gem install caustics
|
|
45
|
+
~~~
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
Render the included scene:
|
|
50
|
+
|
|
51
|
+
~~~sh
|
|
52
|
+
caustics render scenes/weekend.rb --size 400x225 --spp 16 --workers 4 -o out.png
|
|
53
|
+
caustics preview scenes/weekend.rb --size 400x225 --spp 16 -o preview.png
|
|
54
|
+
~~~
|
|
55
|
+
|
|
56
|
+
Use the Ruby API directly:
|
|
57
|
+
|
|
58
|
+
~~~ruby
|
|
59
|
+
require "caustics"
|
|
60
|
+
|
|
61
|
+
scene = Caustics::Scene.build do
|
|
62
|
+
sphere center: [0, 0, 0], radius: 0.5,
|
|
63
|
+
material: lambertian([0.8, 0.2, 0.2])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Caustics.render(scene, width: 320, height: 180, spp: 16, workers: 4).write("out.png")
|
|
67
|
+
~~~
|
|
68
|
+
|
|
69
|
+
## Engines
|
|
70
|
+
|
|
71
|
+
~~~sh
|
|
72
|
+
caustics bench scenes/shader.rb --size 64x36 --spp 2 --engine ruby,rlsl_c
|
|
73
|
+
caustics render scenes/shader.rb --engine rlsl_c --size 400x225 --spp 2 --max-depth 2 -o out.png
|
|
74
|
+
caustics codegen scenes/shader.rb
|
|
75
|
+
~~~
|
|
76
|
+
|
|
77
|
+
The <code>:rlsl_c</code> and <code>:metal</code> engines currently support
|
|
78
|
+
constant-colored Lambertian spheres with deterministic sampling and bounce
|
|
79
|
+
limits. The Metal engine requires a Metal-capable macOS host.
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
~~~sh
|
|
84
|
+
bundle install
|
|
85
|
+
bundle exec rake verify
|
|
86
|
+
~~~
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
[MIT](LICENSE.txt)
|
data/Rakefile
ADDED
data/docs/spikes.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Caustics shader checks
|
|
2
|
+
|
|
3
|
+
The RLSL engine uses generated, unrolled scalar expressions. The supported
|
|
4
|
+
shader subset is intentionally narrower than the Ruby renderer: constant
|
|
5
|
+
colored Lambertian spheres, deterministic sampling, and bounded bounce loops.
|
|
6
|
+
|
|
7
|
+
The code generator and its specs verify:
|
|
8
|
+
|
|
9
|
+
- generated fragment source builds through the RLSL C target;
|
|
10
|
+
- fixed loop bounds are represented by scalar `while` loops;
|
|
11
|
+
- sphere constants and indexed scene expansion are emitted without runtime
|
|
12
|
+
Ruby objects;
|
|
13
|
+
- integer uniforms carry `spp`, `max_depth`, and `seed` into the generated
|
|
14
|
+
shader;
|
|
15
|
+
- unsupported materials and object counts fail before compilation.
|
|
16
|
+
|
|
17
|
+
Struct and array heavy scene descriptions, arbitrary materials, and Ractor
|
|
18
|
+
workers remain outside this engine's supported subset. The Ruby and parallel
|
|
19
|
+
engines are the reference implementation for those cases.
|
data/exe/caustics
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "optparse"
|
|
5
|
+
require "caustics"
|
|
6
|
+
|
|
7
|
+
command = ARGV.shift
|
|
8
|
+
unless %w[render bench codegen preview].include?(command)
|
|
9
|
+
warn "usage: caustics render|bench|codegen|preview SCENE.rb [--size WxH] [--spp N] [--workers N] [--engine NAME,...] [-o FILE]"
|
|
10
|
+
exit 1
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
path = ARGV.shift
|
|
14
|
+
raise ArgumentError, "scene file is required" unless path
|
|
15
|
+
output = "out.png"
|
|
16
|
+
size = [400, 225]
|
|
17
|
+
spp = 4
|
|
18
|
+
workers = 1
|
|
19
|
+
seed = 42
|
|
20
|
+
max_depth = 8
|
|
21
|
+
engines = [:ruby]
|
|
22
|
+
OptionParser.new do |options|
|
|
23
|
+
options.on("-o FILE") { |value| output = value }
|
|
24
|
+
options.on("--size SIZE") { |value| size = value.split("x").map(&:to_i) }
|
|
25
|
+
options.on("--spp N", Integer) { |value| spp = value }
|
|
26
|
+
options.on("--workers N", Integer) { |value| workers = value }
|
|
27
|
+
options.on("--seed N", Integer) { |value| seed = value }
|
|
28
|
+
options.on("--max-depth N", Integer) { |value| max_depth = value }
|
|
29
|
+
options.on("--engine NAME") { |value| engines = value.split(",").map { |name| name.to_sym } }
|
|
30
|
+
end.parse!(ARGV)
|
|
31
|
+
raise ArgumentError, "size must be WIDTHxHEIGHT" unless size.length == 2 && size.all?(&:positive?)
|
|
32
|
+
load path
|
|
33
|
+
scene = defined?(SCENE) ? SCENE : $scene
|
|
34
|
+
raise ArgumentError, "scene file must assign SCENE or $scene" unless scene
|
|
35
|
+
|
|
36
|
+
case command
|
|
37
|
+
when "render"
|
|
38
|
+
raise ArgumentError, "render accepts one engine" unless engines.one?
|
|
39
|
+
Caustics.render(scene, width: size[0], height: size[1], spp: spp, max_depth: max_depth, workers: workers, seed: seed, engine: engines.first).write(output)
|
|
40
|
+
puts output
|
|
41
|
+
when "bench"
|
|
42
|
+
results = Caustics.benchmark(scene, engines: engines, width: size[0], height: size[1], spp: spp, max_depth: max_depth)
|
|
43
|
+
baseline = results[:ruby] || results.values.first
|
|
44
|
+
puts "engine seconds ratio"
|
|
45
|
+
results.each do |name, seconds|
|
|
46
|
+
puts format("%s %.3fs %.2fx", name, seconds, seconds / baseline)
|
|
47
|
+
end
|
|
48
|
+
when "codegen"
|
|
49
|
+
puts Caustics::Engines::ShaderCodegen.generate(scene)
|
|
50
|
+
when "preview"
|
|
51
|
+
Caustics.preview(scene, width: size[0], height: size[1], spp: spp, max_depth: max_depth, seed: seed, output: output)
|
|
52
|
+
end
|
data/lib/caustics.rb
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tessel"
|
|
4
|
+
|
|
5
|
+
require_relative "caustics/version"
|
|
6
|
+
|
|
7
|
+
module Caustics
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
class UnsupportedError < ArgumentError; end
|
|
10
|
+
|
|
11
|
+
class Vec3
|
|
12
|
+
attr_accessor :x, :y, :z
|
|
13
|
+
def initialize(x = 0, y = 0, z = 0) = (@x, @y, @z = x.to_f, y.to_f, z.to_f)
|
|
14
|
+
def +(other) = Vec3.new(x + other.x, y + other.y, z + other.z)
|
|
15
|
+
def -(other) = Vec3.new(x - other.x, y - other.y, z - other.z)
|
|
16
|
+
def -@ = Vec3.new(-x, -y, -z)
|
|
17
|
+
def *(other) = other.is_a?(Vec3) ? Vec3.new(x * other.x, y * other.y, z * other.z) : Vec3.new(x * other, y * other, z * other)
|
|
18
|
+
def /(other) = self * (1.0 / other)
|
|
19
|
+
def dot(other) = x * other.x + y * other.y + z * other.z
|
|
20
|
+
def cross(other) = Vec3.new(y * other.z - z * other.y, z * other.x - x * other.z, x * other.y - y * other.x)
|
|
21
|
+
def length = Math.sqrt(dot(self))
|
|
22
|
+
def normalize = length.zero? ? self : self / length
|
|
23
|
+
def near_zero? = [x, y, z].all? { |value| value.abs < 1e-8 }
|
|
24
|
+
def to_a = [x, y, z]
|
|
25
|
+
def self.random(rng, min: 0.0, max: 1.0) = new(rng.rand(min...max), rng.rand(min...max), rng.rand(min...max))
|
|
26
|
+
def self.random_unit(rng)
|
|
27
|
+
loop do
|
|
28
|
+
value = new(rng.rand(-1.0...1.0), rng.rand(-1.0...1.0), rng.rand(-1.0...1.0))
|
|
29
|
+
next if value.dot(value) >= 1 || value.near_zero?
|
|
30
|
+
return value.normalize
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
def reflect(normal) = self - normal * 2 * dot(normal)
|
|
34
|
+
def refract(normal, ratio)
|
|
35
|
+
cos_theta = [-self.dot(normal), 1.0].min
|
|
36
|
+
perpendicular = (self + normal * cos_theta) * ratio
|
|
37
|
+
parallel = normal * -Math.sqrt((1.0 - perpendicular.dot(perpendicular)).abs)
|
|
38
|
+
perpendicular + parallel
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Ray = Struct.new(:origin, :direction) do
|
|
43
|
+
def at(distance) = origin + direction * distance
|
|
44
|
+
end
|
|
45
|
+
Hit = Struct.new(:point, :normal, :t, :front_face, :material, keyword_init: true)
|
|
46
|
+
|
|
47
|
+
class Aabb
|
|
48
|
+
attr_reader :minimum, :maximum
|
|
49
|
+
|
|
50
|
+
def initialize(minimum:, maximum:)
|
|
51
|
+
@minimum, @maximum = minimum, maximum
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def hit?(ray, minimum, maximum)
|
|
55
|
+
3.times do |axis|
|
|
56
|
+
origin = ray.origin.to_a[axis]
|
|
57
|
+
direction = ray.direction.to_a[axis]
|
|
58
|
+
lower = @minimum.to_a[axis]
|
|
59
|
+
upper = @maximum.to_a[axis]
|
|
60
|
+
if direction.abs < Float::EPSILON
|
|
61
|
+
return false if origin < lower || origin > upper
|
|
62
|
+
next
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
inverse = 1.0 / direction
|
|
66
|
+
first = (lower - origin) * inverse
|
|
67
|
+
last = (upper - origin) * inverse
|
|
68
|
+
first, last = last, first if first > last
|
|
69
|
+
minimum = first if first > minimum
|
|
70
|
+
maximum = last if last < maximum
|
|
71
|
+
return false if maximum < minimum
|
|
72
|
+
end
|
|
73
|
+
true
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.around(left, right)
|
|
77
|
+
new(
|
|
78
|
+
minimum: Vec3.new(
|
|
79
|
+
[left.minimum.x, right.minimum.x].min,
|
|
80
|
+
[left.minimum.y, right.minimum.y].min,
|
|
81
|
+
[left.minimum.z, right.minimum.z].min
|
|
82
|
+
),
|
|
83
|
+
maximum: Vec3.new(
|
|
84
|
+
[left.maximum.x, right.maximum.x].max,
|
|
85
|
+
[left.maximum.y, right.maximum.y].max,
|
|
86
|
+
[left.maximum.z, right.maximum.z].max
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class BvhNode
|
|
93
|
+
attr_reader :box
|
|
94
|
+
|
|
95
|
+
def initialize(objects)
|
|
96
|
+
raise ArgumentError, "BVH needs at least one object" if objects.empty?
|
|
97
|
+
|
|
98
|
+
if objects.length == 1
|
|
99
|
+
@left = @right = objects.first
|
|
100
|
+
@box = @left.bounding_box
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
axis = longest_axis(objects)
|
|
105
|
+
sorted = objects.sort_by { |object| object.bounding_box.minimum.to_a[axis] + object.bounding_box.maximum.to_a[axis] }
|
|
106
|
+
midpoint = sorted.length / 2
|
|
107
|
+
@left = sorted.take(midpoint)
|
|
108
|
+
@right = sorted.drop(midpoint)
|
|
109
|
+
@left = @left.length == 1 ? @left.first : BvhNode.new(@left)
|
|
110
|
+
@right = @right.length == 1 ? @right.first : BvhNode.new(@right)
|
|
111
|
+
@box = Aabb.around(@left.bounding_box, @right.bounding_box)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def hit(ray, minimum, maximum)
|
|
115
|
+
return unless @box.hit?(ray, minimum, maximum)
|
|
116
|
+
|
|
117
|
+
left_hit = @left.hit(ray, minimum, maximum)
|
|
118
|
+
right_limit = left_hit ? left_hit.t : maximum
|
|
119
|
+
right_hit = @right.equal?(@left) ? nil : @right.hit(ray, minimum, right_limit)
|
|
120
|
+
right_hit || left_hit
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def bounding_box = @box
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def longest_axis(objects)
|
|
128
|
+
bounds = objects.map(&:bounding_box).reduce { |left, right| Aabb.around(left, right) }
|
|
129
|
+
lengths = bounds.maximum.to_a.zip(bounds.minimum.to_a).map { |max, min| max - min }
|
|
130
|
+
lengths.each_with_index.max_by(&:first).last
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
class Sphere
|
|
135
|
+
attr_reader :center, :radius, :material
|
|
136
|
+
def initialize(center:, radius:, material:)
|
|
137
|
+
@center, @radius, @material = center, radius.to_f, material
|
|
138
|
+
raise ArgumentError, "sphere radius must be positive" unless @radius.positive?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def hit(ray, minimum, maximum)
|
|
142
|
+
offset = ray.origin - @center
|
|
143
|
+
half_b = offset.dot(ray.direction)
|
|
144
|
+
discriminant = half_b * half_b - ray.direction.dot(ray.direction) * (offset.dot(offset) - @radius * @radius)
|
|
145
|
+
return unless discriminant >= 0
|
|
146
|
+
root = Math.sqrt(discriminant)
|
|
147
|
+
root = (-half_b - root) / ray.direction.dot(ray.direction)
|
|
148
|
+
root = (-half_b + root) / ray.direction.dot(ray.direction) unless root.between?(minimum, maximum)
|
|
149
|
+
return unless root.between?(minimum, maximum)
|
|
150
|
+
|
|
151
|
+
point = ray.at(root)
|
|
152
|
+
outward = (point - @center) / @radius
|
|
153
|
+
front_face = ray.direction.dot(outward).negative?
|
|
154
|
+
Hit.new(point: point, normal: front_face ? outward : -outward, t: root, front_face: front_face, material: @material)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def bounding_box
|
|
158
|
+
radius = Vec3.new(@radius, @radius, @radius)
|
|
159
|
+
Aabb.new(minimum: @center - radius, maximum: @center + radius)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class Plane
|
|
164
|
+
attr_reader :point, :normal, :material
|
|
165
|
+
|
|
166
|
+
def initialize(point:, normal:, material:)
|
|
167
|
+
@point, @normal, @material = point, normal.normalize, material
|
|
168
|
+
raise ArgumentError, "plane normal must not be zero" if @normal.near_zero?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def hit(ray, minimum, maximum)
|
|
172
|
+
denominator = ray.direction.dot(@normal)
|
|
173
|
+
return if denominator.abs < Float::EPSILON
|
|
174
|
+
|
|
175
|
+
distance = (@point - ray.origin).dot(@normal) / denominator
|
|
176
|
+
return unless distance.between?(minimum, maximum)
|
|
177
|
+
|
|
178
|
+
front_face = ray.direction.dot(@normal).negative?
|
|
179
|
+
Hit.new(point: ray.at(distance), normal: front_face ? @normal : -@normal, t: distance,
|
|
180
|
+
front_face: front_face, material: @material)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class Triangle
|
|
185
|
+
attr_reader :a, :b, :c, :material
|
|
186
|
+
|
|
187
|
+
def initialize(a:, b:, c:, material:)
|
|
188
|
+
@a, @b, @c, @material = a, b, c, material
|
|
189
|
+
raise ArgumentError, "triangle vertices must not be collinear" if edge1.cross(edge2).near_zero?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def hit(ray, minimum, maximum)
|
|
193
|
+
h = ray.direction.cross(edge2)
|
|
194
|
+
determinant = edge1.dot(h)
|
|
195
|
+
return if determinant.abs < Float::EPSILON
|
|
196
|
+
|
|
197
|
+
inverse = 1.0 / determinant
|
|
198
|
+
s = ray.origin - @a
|
|
199
|
+
u = inverse * s.dot(h)
|
|
200
|
+
return unless u.between?(0.0, 1.0)
|
|
201
|
+
|
|
202
|
+
q = s.cross(edge1)
|
|
203
|
+
v = inverse * ray.direction.dot(q)
|
|
204
|
+
return unless v >= 0.0 && u + v <= 1.0
|
|
205
|
+
|
|
206
|
+
distance = inverse * edge2.dot(q)
|
|
207
|
+
return unless distance.between?(minimum, maximum)
|
|
208
|
+
|
|
209
|
+
outward = edge1.cross(edge2).normalize
|
|
210
|
+
front_face = ray.direction.dot(outward).negative?
|
|
211
|
+
Hit.new(point: ray.at(distance), normal: front_face ? outward : -outward, t: distance,
|
|
212
|
+
front_face: front_face, material: @material)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def bounding_box
|
|
216
|
+
points = [@a.to_a, @b.to_a, @c.to_a]
|
|
217
|
+
minimum = 3.times.map { |axis| points.map { |point| point[axis] }.min }
|
|
218
|
+
maximum = 3.times.map { |axis| points.map { |point| point[axis] }.max }
|
|
219
|
+
Aabb.new(minimum: Vec3.new(*minimum), maximum: Vec3.new(*maximum))
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def edge1 = @b - @a
|
|
225
|
+
def edge2 = @c - @a
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class Quad
|
|
229
|
+
attr_reader :material
|
|
230
|
+
|
|
231
|
+
def initialize(a:, b:, c:, d:, material:)
|
|
232
|
+
@material = material
|
|
233
|
+
@triangles = [Triangle.new(a: a, b: b, c: c, material: material),
|
|
234
|
+
Triangle.new(a: a, b: c, c: d, material: material)]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def hit(ray, minimum, maximum)
|
|
238
|
+
@triangles.filter_map { |triangle| triangle.hit(ray, minimum, maximum) }.min_by(&:t)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def bounding_box
|
|
242
|
+
@triangles.map(&:bounding_box).reduce { |left, right| Aabb.around(left, right) }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
class Material
|
|
247
|
+
def initialize(color: Vec3.new(1, 1, 1), fuzz: 0, index: 1.5, emission: Vec3.new(0, 0, 0), kind: :lambertian)
|
|
248
|
+
@index = Float(index)
|
|
249
|
+
raise ArgumentError, "refractive index must be positive" unless @index.positive?
|
|
250
|
+
@color, @fuzz, @emission, @kind = color, Float(fuzz).clamp(0, 1), emission, kind
|
|
251
|
+
end
|
|
252
|
+
attr_reader :color, :emission, :index, :kind
|
|
253
|
+
|
|
254
|
+
def scatter(ray, hit, rng)
|
|
255
|
+
case @kind
|
|
256
|
+
when :lambertian
|
|
257
|
+
direction = hit.normal + Vec3.random_unit(rng)
|
|
258
|
+
direction = hit.normal if direction.near_zero?
|
|
259
|
+
[Ray.new(hit.point, direction), @color.respond_to?(:value) ? @color.value(hit.point) : @color]
|
|
260
|
+
when :metal
|
|
261
|
+
direction = ray.direction.normalize.reflect(hit.normal) + Vec3.random_unit(rng) * @fuzz
|
|
262
|
+
direction.dot(hit.normal).positive? ? [Ray.new(hit.point, direction), @color] : nil
|
|
263
|
+
when :dielectric
|
|
264
|
+
ratio = hit.front_face ? 1.0 / @index : @index
|
|
265
|
+
cosine = [-ray.direction.normalize.dot(hit.normal), 1.0].min
|
|
266
|
+
sine = Math.sqrt(1.0 - cosine * cosine)
|
|
267
|
+
cannot_refract = ratio * sine > 1
|
|
268
|
+
reflectance = ((1 - ratio) / (1 + ratio))**2
|
|
269
|
+
reflectance += (1 - reflectance) * (1 - cosine)**5
|
|
270
|
+
direction = cannot_refract || reflectance > rng.rand ? ray.direction.reflect(hit.normal) : ray.direction.refract(hit.normal, ratio)
|
|
271
|
+
[Ray.new(hit.point, direction), Vec3.new(1, 1, 1)]
|
|
272
|
+
else nil
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
class Camera
|
|
278
|
+
attr_reader :origin, :horizontal, :vertical, :lower_left
|
|
279
|
+
def initialize(from: [13, 2, 3], to: [0, 0, 0], up: [0, 1, 0], fov: 20, aspect: 16.0 / 9, aperture: 0, focus_distance: 10)
|
|
280
|
+
@origin = vec(from)
|
|
281
|
+
raise ArgumentError, "camera fov must be between 0 and 180 degrees" unless fov.to_f.positive? && fov.to_f < 180
|
|
282
|
+
raise ArgumentError, "camera aspect must be positive" unless aspect.to_f.positive?
|
|
283
|
+
raise ArgumentError, "camera focus distance must be positive" unless focus_distance.to_f.positive?
|
|
284
|
+
raise ArgumentError, "camera aperture must not be negative" if aperture.to_f.negative?
|
|
285
|
+
theta = fov * Math::PI / 180
|
|
286
|
+
viewport_height = 2 * Math.tan(theta / 2)
|
|
287
|
+
viewport_width = aspect * viewport_height
|
|
288
|
+
forward = (vec(to) - @origin).normalize
|
|
289
|
+
raise ArgumentError, "camera target must differ from origin" if forward.near_zero?
|
|
290
|
+
right = forward.cross(vec(up)).normalize
|
|
291
|
+
raise ArgumentError, "camera up must not be parallel to view direction" if right.near_zero?
|
|
292
|
+
camera_up = right.cross(forward)
|
|
293
|
+
@horizontal = right * (focus_distance * viewport_width)
|
|
294
|
+
@vertical = camera_up * (focus_distance * viewport_height)
|
|
295
|
+
@lower_left = @origin - @horizontal / 2 - @vertical / 2 + forward * focus_distance
|
|
296
|
+
@lens_radius = aperture / 2
|
|
297
|
+
@right, @up = right, camera_up
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def ray(u, v, rng)
|
|
301
|
+
offset = random_disk(rng) * @lens_radius
|
|
302
|
+
Ray.new(@origin + @right * offset.x + @up * offset.y, @lower_left + @horizontal * u + @vertical * v - @origin - @right * offset.x - @up * offset.y)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
private
|
|
306
|
+
|
|
307
|
+
def vec(value)
|
|
308
|
+
value.is_a?(Vec3) ? value : Vec3.new(*value)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def random_disk(rng)
|
|
312
|
+
loop do
|
|
313
|
+
point = Vec3.new(rng.rand(-1.0...1.0), rng.rand(-1.0...1.0), 0)
|
|
314
|
+
return point if point.dot(point) < 1
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
class Scene
|
|
320
|
+
attr_accessor :camera, :sky
|
|
321
|
+
attr_reader :objects
|
|
322
|
+
|
|
323
|
+
def initialize
|
|
324
|
+
@objects = []
|
|
325
|
+
@camera = Camera.new
|
|
326
|
+
@sky = true
|
|
327
|
+
@bvh = nil
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def self.build(&block)
|
|
331
|
+
scene = new
|
|
332
|
+
scene.instance_eval(&block)
|
|
333
|
+
scene
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def camera(**options)
|
|
337
|
+
return @camera if options.empty?
|
|
338
|
+
@camera = Camera.new(**options.merge(aspect: options.fetch(:aspect, 16.0 / 9)))
|
|
339
|
+
end
|
|
340
|
+
def background(sky: true) = (@sky = sky)
|
|
341
|
+
def objects=(objects)
|
|
342
|
+
@objects = objects
|
|
343
|
+
@bvh = nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def sphere(center:, radius:, material:)
|
|
347
|
+
add_object(Sphere.new(center: vec(center), radius: radius, material: material))
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def plane(point:, normal:, material:)
|
|
351
|
+
add_object(Plane.new(point: vec(point), normal: vec(normal), material: material))
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def triangle(a:, b:, c:, material:)
|
|
355
|
+
add_object(Triangle.new(a: vec(a), b: vec(b), c: vec(c), material: material))
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def quad(a:, b:, c:, d:, material:)
|
|
359
|
+
add_object(Quad.new(a: vec(a), b: vec(b), c: vec(c), d: vec(d), material: material))
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def hit(ray, minimum, maximum)
|
|
363
|
+
rebuild_acceleration! if @bvh_objects != @objects
|
|
364
|
+
candidates = []
|
|
365
|
+
candidates << @bvh.hit(ray, minimum, maximum) if @bvh
|
|
366
|
+
candidates.concat(@unbounded_objects.filter_map { |object| object.hit(ray, minimum, maximum) })
|
|
367
|
+
candidates.compact.min_by(&:t)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def rebuild_acceleration!
|
|
371
|
+
bounded, @unbounded_objects = @objects.partition { |object| object.respond_to?(:bounding_box) }
|
|
372
|
+
@bvh = bounded.empty? ? nil : BvhNode.new(bounded)
|
|
373
|
+
@bvh_objects = @objects.dup
|
|
374
|
+
end
|
|
375
|
+
def lambertian(color = [0.5, 0.5, 0.5]) = Material.new(color: color.respond_to?(:value) ? color : vec(color), kind: :lambertian)
|
|
376
|
+
def metal(color = [0.8, 0.8, 0.8], fuzz: 0) = Material.new(color: vec(color), fuzz: fuzz, kind: :metal)
|
|
377
|
+
def dielectric(index = 1.5) = Material.new(index: index, kind: :dielectric)
|
|
378
|
+
def diffuse_light(color) = Material.new(emission: vec(color), kind: :light)
|
|
379
|
+
def checker(first, second, scale: 1) = Texture.new(first: vec(first), second: vec(second), scale: scale)
|
|
380
|
+
def image_texture(path, scale: 1) = ImageTexture.from_file(path, scale: scale)
|
|
381
|
+
def noise_texture(scale: 1, seed: 42) = NoiseTexture.new(scale: scale, seed: seed)
|
|
382
|
+
|
|
383
|
+
private
|
|
384
|
+
|
|
385
|
+
def vec(value)
|
|
386
|
+
value.is_a?(Vec3) ? value : Vec3.new(*value)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def add_object(object)
|
|
390
|
+
@objects << object
|
|
391
|
+
@bvh = nil
|
|
392
|
+
object
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
class Texture
|
|
397
|
+
def initialize(first:, second:, scale: 1) = (@first, @second, @scale = first, second, scale)
|
|
398
|
+
def value(point) = ((Math.sin(point.x * @scale * Math::PI) * Math.sin(point.z * @scale * Math::PI)).negative? ? @second : @first)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
class ImageTexture
|
|
402
|
+
def initialize(image:, scale: 1)
|
|
403
|
+
@image, @scale = image, Float(scale)
|
|
404
|
+
raise ArgumentError, "image texture scale must be positive" unless @scale.positive?
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def self.from_file(path, scale: 1)
|
|
408
|
+
new(image: Tessel.read(path), scale: scale)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def value(point)
|
|
412
|
+
return Vec3.new(0, 0, 0) if @image.width.zero? || @image.height.zero?
|
|
413
|
+
|
|
414
|
+
u = (point.x * @scale).floor % @image.width
|
|
415
|
+
v = (point.z * @scale).floor % @image.height
|
|
416
|
+
red, green, blue, = @image[u, v]
|
|
417
|
+
Vec3.new(red / 255.0, green / 255.0, blue / 255.0)
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
class Rng
|
|
422
|
+
def initialize(seed = 42) = (@random = Random.new(seed))
|
|
423
|
+
def rand(*args) = @random.rand(*args)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
class Perlin
|
|
427
|
+
def initialize(seed = 42)
|
|
428
|
+
rng = Rng.new(seed)
|
|
429
|
+
@vectors = Array.new(256) { Vec3.random_unit(rng) }
|
|
430
|
+
@x = permutation(rng)
|
|
431
|
+
@y = permutation(rng)
|
|
432
|
+
@z = permutation(rng)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def noise(point)
|
|
436
|
+
cell = [point.x.floor, point.y.floor, point.z.floor]
|
|
437
|
+
fraction = [point.x - cell[0], point.y - cell[1], point.z - cell[2]]
|
|
438
|
+
smooth = fraction.map { |value| value * value * (3 - 2 * value) }
|
|
439
|
+
value = 0.0
|
|
440
|
+
2.times do |i|
|
|
441
|
+
2.times do |j|
|
|
442
|
+
2.times do |k|
|
|
443
|
+
index = @x[(cell[0] + i) & 255] ^ @y[(cell[1] + j) & 255] ^ @z[(cell[2] + k) & 255]
|
|
444
|
+
weight = Vec3.new(fraction[0] - i, fraction[1] - j, fraction[2] - k)
|
|
445
|
+
blend = (i.zero? ? 1 - smooth[0] : smooth[0]) * (j.zero? ? 1 - smooth[1] : smooth[1]) * (k.zero? ? 1 - smooth[2] : smooth[2])
|
|
446
|
+
value += blend * @vectors[index].dot(weight)
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
value
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def turbulence(point, depth: 7)
|
|
454
|
+
weight = 1.0
|
|
455
|
+
result = 0.0
|
|
456
|
+
depth.times do
|
|
457
|
+
result += weight * noise(point)
|
|
458
|
+
weight *= 0.5
|
|
459
|
+
point *= 2
|
|
460
|
+
end
|
|
461
|
+
result.abs
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
private
|
|
465
|
+
|
|
466
|
+
def permutation(rng)
|
|
467
|
+
values = (0...256).to_a
|
|
468
|
+
255.downto(1) do |index|
|
|
469
|
+
swap = rng.rand(index + 1)
|
|
470
|
+
values[index], values[swap] = values[swap], values[index]
|
|
471
|
+
end
|
|
472
|
+
values
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
class NoiseTexture
|
|
477
|
+
def initialize(scale: 1, seed: 42)
|
|
478
|
+
@scale = Float(scale)
|
|
479
|
+
raise ArgumentError, "noise texture scale must be positive" unless @scale.positive?
|
|
480
|
+
@noise = Perlin.new(seed)
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def value(point)
|
|
484
|
+
value = 0.5 * (1.0 + Math.sin(@scale * point.z + 10.0 * @noise.turbulence(point * @scale)))
|
|
485
|
+
Vec3.new(value, value, value)
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
module Renderer
|
|
490
|
+
module_function
|
|
491
|
+
|
|
492
|
+
def render(scene, width:, height:, spp:, max_depth:, seed: 42, progress: nil)
|
|
493
|
+
Tessel::Image.from_rgba(width, height, render_rows(scene, width: width, height: height, y_range: 0...height, spp: spp, max_depth: max_depth, seed: seed, progress: progress))
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def render_rows(scene, width:, height:, y_range:, spp:, max_depth:, seed:, progress: nil)
|
|
497
|
+
raise ArgumentError, "dimensions, spp and max_depth must be positive" unless [width, height, spp, max_depth].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
498
|
+
data = "".b
|
|
499
|
+
scene.rebuild_acceleration!
|
|
500
|
+
y_range.each do |y|
|
|
501
|
+
(0...width).each do |x|
|
|
502
|
+
rng = Rng.new(seed ^ ((y * width + x) * 0x9e37_79b9))
|
|
503
|
+
color = Vec3.new
|
|
504
|
+
spp.times do
|
|
505
|
+
ray = scene.camera.ray((x + rng.rand) / width.to_f, (height - 1 - y + rng.rand) / height.to_f, rng)
|
|
506
|
+
color += trace(ray, scene, rng, max_depth)
|
|
507
|
+
end
|
|
508
|
+
color = color / spp
|
|
509
|
+
data << [Math.sqrt(color.x.clamp(0, 1)), Math.sqrt(color.y.clamp(0, 1)), Math.sqrt(color.z.clamp(0, 1))].map { |channel| (channel * 255.999).to_i }.push(255).pack("C4")
|
|
510
|
+
end
|
|
511
|
+
progress.call(y + 1, height) if progress
|
|
512
|
+
end
|
|
513
|
+
data
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
def trace(ray, scene, rng, depth)
|
|
517
|
+
attenuation = Vec3.new(1, 1, 1)
|
|
518
|
+
depth.times do
|
|
519
|
+
hit = scene.hit(ray, 0.001, Float::INFINITY)
|
|
520
|
+
return attenuation * background(ray, scene) unless hit
|
|
521
|
+
return attenuation * hit.material.emission if hit.material.emission.dot(hit.material.emission).positive?
|
|
522
|
+
scattered = hit.material.scatter(ray, hit, rng)
|
|
523
|
+
return Vec3.new unless scattered
|
|
524
|
+
ray, color = scattered
|
|
525
|
+
attenuation *= color
|
|
526
|
+
end
|
|
527
|
+
Vec3.new
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def background(ray, scene)
|
|
531
|
+
return Vec3.new(0.0, 0.0, 0.0) unless scene.sky
|
|
532
|
+
t = 0.5 * (ray.direction.normalize.y + 1)
|
|
533
|
+
Vec3.new(1, 1, 1) * (1 - t) + Vec3.new(0.5, 0.7, 1.0) * t
|
|
534
|
+
end
|
|
535
|
+
private_class_method :trace, :background
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
class Accumulator
|
|
539
|
+
attr_reader :samples
|
|
540
|
+
def initialize(width, height)
|
|
541
|
+
@width, @height = Integer(width), Integer(height)
|
|
542
|
+
raise ArgumentError, "accumulator dimensions must be positive" unless @width.positive? && @height.positive?
|
|
543
|
+
|
|
544
|
+
@image = Array.new(@width * @height) { Vec3.new }
|
|
545
|
+
@samples = 0
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def add(image)
|
|
549
|
+
raise ArgumentError, "accumulator image dimensions do not match" unless image.width == @width && image.height == @height
|
|
550
|
+
|
|
551
|
+
image.bytes.bytes.each_slice(4).with_index { |(r, g, b, _a), index| @image[index] += Vec3.new(r / 255.0, g / 255.0, b / 255.0) }
|
|
552
|
+
@samples += 1
|
|
553
|
+
Tessel::Image.from_rgba(@width, @height, @image.flat_map { |color| value = color / @samples; [value.x * 255, value.y * 255, value.z * 255, 255].map(&:to_i) }.pack("C*"))
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
module Engines
|
|
558
|
+
module Parallel
|
|
559
|
+
module_function
|
|
560
|
+
|
|
561
|
+
def render(scene, width:, height:, spp:, max_depth:, seed:, workers:, progress: nil)
|
|
562
|
+
return Renderer.render(scene, width: width, height: height, spp: spp, max_depth: max_depth, seed: seed, progress: progress) unless Process.respond_to?(:fork) && RUBY_PLATFORM !~ /mswin|mingw/ && workers > 1
|
|
563
|
+
|
|
564
|
+
chunk = (height + workers - 1) / workers
|
|
565
|
+
jobs = (0...height).each_slice(chunk).map { |rows| rows.first..rows.last }
|
|
566
|
+
children = []
|
|
567
|
+
jobs.each do |range|
|
|
568
|
+
reader, writer = IO.pipe
|
|
569
|
+
pid = Process.fork do
|
|
570
|
+
reader.close
|
|
571
|
+
begin
|
|
572
|
+
writer.write(Renderer.render_rows(scene, width: width, height: height, y_range: range, spp: spp, max_depth: max_depth, seed: seed))
|
|
573
|
+
writer.close
|
|
574
|
+
exit! 0
|
|
575
|
+
rescue StandardError
|
|
576
|
+
writer.close
|
|
577
|
+
exit! 1
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
writer.close
|
|
581
|
+
children << [reader, pid, range]
|
|
582
|
+
end
|
|
583
|
+
completed = 0
|
|
584
|
+
readers = children.map do |reader, pid, range|
|
|
585
|
+
Thread.new { [reader.read, reader, pid, range] }
|
|
586
|
+
end
|
|
587
|
+
data = readers.map do |thread|
|
|
588
|
+
bytes, reader, pid, range = thread.value
|
|
589
|
+
reader.close
|
|
590
|
+
_, status = Process.waitpid2(pid)
|
|
591
|
+
raise Error, "render worker failed" unless status.success? && bytes.bytesize == range.size * width * 4
|
|
592
|
+
completed += 1
|
|
593
|
+
progress.call(completed, jobs.length) if progress
|
|
594
|
+
bytes
|
|
595
|
+
end.join
|
|
596
|
+
Tessel::Image.from_rgba(width, height, data)
|
|
597
|
+
ensure
|
|
598
|
+
readers&.each(&:join)
|
|
599
|
+
children&.each do |reader, pid, _range|
|
|
600
|
+
reader.close unless reader.closed?
|
|
601
|
+
Process.waitpid(pid)
|
|
602
|
+
rescue Errno::ECHILD
|
|
603
|
+
nil
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
module ShaderCodegen
|
|
609
|
+
module_function
|
|
610
|
+
def generate(scene, max_objects: 500)
|
|
611
|
+
raise ArgumentError, "shader engine supports at most #{max_objects} objects" if scene.objects.length > max_objects
|
|
612
|
+
unless scene.objects.all? { |object| object.is_a?(Sphere) && object.material.color.is_a?(Vec3) && object.material.kind == :lambertian }
|
|
613
|
+
raise UnsupportedError, "RLSL codegen supports spheres with constant-color Lambertian materials only"
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
camera = scene.camera
|
|
617
|
+
lines = [
|
|
618
|
+
"|frag_coord, resolution, uniforms|",
|
|
619
|
+
"origin = #{vector(camera.origin)}",
|
|
620
|
+
"sample = 0",
|
|
621
|
+
"radiance = vec3(0.0, 0.0, 0.0)",
|
|
622
|
+
"while sample < uniforms.spp",
|
|
623
|
+
" jitter_x = fract(sin(frag_coord.x + sample + uniforms.seed + 1.0) * 43758.5453)",
|
|
624
|
+
" jitter_y = fract(sin(frag_coord.y + sample + uniforms.seed + 2.0) * 43758.5453)",
|
|
625
|
+
" sample_u = (frag_coord.x + jitter_x) / resolution.x",
|
|
626
|
+
" sample_v = (frag_coord.y + jitter_y) / resolution.y",
|
|
627
|
+
" ray_origin = origin",
|
|
628
|
+
" ray_direction = #{vector(camera.lower_left)} + #{vector(camera.horizontal)} * sample_u + #{vector(camera.vertical)} * sample_v - ray_origin",
|
|
629
|
+
" throughput = vec3(1.0, 1.0, 1.0)",
|
|
630
|
+
" depth = 0",
|
|
631
|
+
" while depth < uniforms.max_depth",
|
|
632
|
+
" closest = 1000000000.0",
|
|
633
|
+
" hit = false",
|
|
634
|
+
" hit_point = vec3(0.0, 0.0, 0.0)",
|
|
635
|
+
" hit_normal = vec3(0.0, 1.0, 0.0)",
|
|
636
|
+
" hit_color = vec3(0.0, 0.0, 0.0)"
|
|
637
|
+
]
|
|
638
|
+
scene.objects.each_with_index do |object, index|
|
|
639
|
+
center = vector(object.center)
|
|
640
|
+
color = vector(object.material.color)
|
|
641
|
+
lines.concat([
|
|
642
|
+
"center_#{index} = #{center}",
|
|
643
|
+
"offset_#{index} = ray_origin - center_#{index}",
|
|
644
|
+
"half_b_#{index} = dot(offset_#{index}, ray_direction)",
|
|
645
|
+
"a_#{index} = dot(ray_direction, ray_direction)",
|
|
646
|
+
"discriminant_#{index} = half_b_#{index} * half_b_#{index} - a_#{index} * (dot(offset_#{index}, offset_#{index}) - #{float(object.radius * object.radius)})",
|
|
647
|
+
"if discriminant_#{index} >= 0.0",
|
|
648
|
+
" root_#{index} = (-half_b_#{index} - sqrt(discriminant_#{index})) / a_#{index}",
|
|
649
|
+
" if root_#{index} <= 0.001 || root_#{index} >= closest",
|
|
650
|
+
" root_#{index} = (-half_b_#{index} + sqrt(discriminant_#{index})) / a_#{index}",
|
|
651
|
+
" end",
|
|
652
|
+
" if root_#{index} > 0.001 && root_#{index} < closest",
|
|
653
|
+
" closest = root_#{index}",
|
|
654
|
+
" hit = true",
|
|
655
|
+
" hit_point = ray_origin + ray_direction * root_#{index}",
|
|
656
|
+
" hit_normal = normalize(hit_point - center_#{index})",
|
|
657
|
+
" hit_color = #{color}",
|
|
658
|
+
" end",
|
|
659
|
+
"end"
|
|
660
|
+
])
|
|
661
|
+
end
|
|
662
|
+
lines.concat([
|
|
663
|
+
" if hit",
|
|
664
|
+
" scatter_x = fract(sin(frag_coord.x + sample + depth + uniforms.seed + 3.0) * 43758.5453) * 2.0 - 1.0",
|
|
665
|
+
" scatter_y = fract(sin(frag_coord.y + sample + depth + uniforms.seed + 4.0) * 43758.5453) * 2.0 - 1.0",
|
|
666
|
+
" scatter_z = fract(sin(frag_coord.x + frag_coord.y + sample + depth + uniforms.seed + 5.0) * 43758.5453) * 2.0 - 1.0",
|
|
667
|
+
" scatter = normalize(vec3(scatter_x, scatter_y, scatter_z))",
|
|
668
|
+
" ray_origin = hit_point",
|
|
669
|
+
" ray_direction = normalize(hit_normal + scatter)",
|
|
670
|
+
" throughput = throughput * hit_color",
|
|
671
|
+
" else",
|
|
672
|
+
" unit_direction = normalize(ray_direction)",
|
|
673
|
+
scene.sky ? " background_t = 0.5 * (unit_direction.y + 1.0)" : " background_t = 0.0",
|
|
674
|
+
scene.sky ? " radiance += throughput * (vec3(1.0, 1.0, 1.0) * (1.0 - background_t) + vec3(0.5, 0.7, 1.0) * background_t)" : " radiance += throughput * vec3(0.0, 0.0, 0.0)",
|
|
675
|
+
" depth = uniforms.max_depth",
|
|
676
|
+
" end",
|
|
677
|
+
" depth += 1",
|
|
678
|
+
" end",
|
|
679
|
+
" sample += 1",
|
|
680
|
+
"end",
|
|
681
|
+
"radiance / (uniforms.spp + 0.0)"
|
|
682
|
+
])
|
|
683
|
+
lines.join("\n")
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def vector(value) = "vec3(#{value.to_a.map { |component| float(component) }.join(', ')})"
|
|
687
|
+
private_class_method :vector
|
|
688
|
+
|
|
689
|
+
def float(value)
|
|
690
|
+
format("%.9f", Float(value))
|
|
691
|
+
end
|
|
692
|
+
private_class_method :float
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
module RlslC
|
|
696
|
+
module_function
|
|
697
|
+
|
|
698
|
+
def render(scene, width:, height:, spp:, max_depth:, seed:)
|
|
699
|
+
begin
|
|
700
|
+
require "rlsl"
|
|
701
|
+
rescue LoadError => error
|
|
702
|
+
raise UnsupportedError, "RLSL is required for the :rlsl_c engine: #{error.message}"
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
builder = RLSL::ShaderBuilder.new(:caustics_scene)
|
|
706
|
+
builder.uniforms { int :spp; int :max_depth; int :seed }
|
|
707
|
+
builder.fragment_source(ShaderCodegen.generate(scene))
|
|
708
|
+
shader = builder.compile_and_load
|
|
709
|
+
bgra = "\0".b * (width * height * 4)
|
|
710
|
+
shader.render(bgra, width, height, spp: spp, max_depth: max_depth, seed: seed)
|
|
711
|
+
rgba = bgra.bytes.each_slice(4).flat_map { |blue, green, red, alpha| [red, green, blue, alpha] }.pack("C*")
|
|
712
|
+
Tessel::Image.from_rgba(width, height, rgba)
|
|
713
|
+
end
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
module Metal
|
|
717
|
+
module_function
|
|
718
|
+
|
|
719
|
+
def render(scene, width:, height:, spp:, max_depth:, seed:)
|
|
720
|
+
begin
|
|
721
|
+
require "metaco"
|
|
722
|
+
require "rlsl"
|
|
723
|
+
Metaco.init
|
|
724
|
+
handle = Metaco.window_create(width, height, "Caustics")
|
|
725
|
+
raise UnsupportedError, "Metal is not available" unless Metaco.metal_compute_available?(handle)
|
|
726
|
+
|
|
727
|
+
builder = RLSL::ShaderBuilder.new(:caustics_metal_scene)
|
|
728
|
+
builder.uniforms { int :spp; int :max_depth; int :seed }
|
|
729
|
+
builder.fragment_source(ShaderCodegen.generate(scene))
|
|
730
|
+
shader = builder.build_metal_shader
|
|
731
|
+
shader.render_metal(handle, width, height, { spp: spp, max_depth: max_depth, seed: seed })
|
|
732
|
+
Tessel::Image.from_rgba(width, height, Metaco.read_pixels(handle, source: :compute))
|
|
733
|
+
rescue LoadError => error
|
|
734
|
+
raise UnsupportedError, "Metaco and RLSL are required for the :metal engine: #{error.message}"
|
|
735
|
+
ensure
|
|
736
|
+
Metaco.window_destroy(handle) if handle
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
|
|
742
|
+
module_function
|
|
743
|
+
|
|
744
|
+
def preview(scene, width:, height:, spp: 16, max_depth: 8, seed: 42, backend: :auto, output: nil)
|
|
745
|
+
require "rbgl"
|
|
746
|
+
spp = Integer(spp)
|
|
747
|
+
raise ArgumentError, "preview spp must be positive" unless spp.positive?
|
|
748
|
+
|
|
749
|
+
window = RBGL::GUI::Window.new(width: width, height: height, title: "caustics", backend: backend)
|
|
750
|
+
accumulator = Accumulator.new(width, height)
|
|
751
|
+
image = Tessel::Image.new(width, height)
|
|
752
|
+
paused = false
|
|
753
|
+
until window.should_close?
|
|
754
|
+
events = Array(window.poll_events_raw)
|
|
755
|
+
events.each do |event|
|
|
756
|
+
key = event[:key]&.to_sym
|
|
757
|
+
character = event[:char]
|
|
758
|
+
if key == :close || key == :escape || character == "\u0003"
|
|
759
|
+
window.close
|
|
760
|
+
elsif key == :space || character == " "
|
|
761
|
+
paused = !paused
|
|
762
|
+
elsif character == "+" || character == "="
|
|
763
|
+
spp += 1
|
|
764
|
+
elsif character == "-"
|
|
765
|
+
spp = [spp - 1, 1].max
|
|
766
|
+
elsif %w[s S].include?(character)
|
|
767
|
+
image.write(output) if output
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
break if window.should_close?
|
|
771
|
+
unless paused || accumulator.samples >= spp
|
|
772
|
+
image = accumulator.add(render(scene, width: width, height: height, spp: 1, max_depth: max_depth, seed: seed + accumulator.samples))
|
|
773
|
+
window.set_pixels(image.bytes)
|
|
774
|
+
end
|
|
775
|
+
sleep(0.01) if paused || accumulator.samples >= spp
|
|
776
|
+
end
|
|
777
|
+
image.write(output) if output
|
|
778
|
+
image
|
|
779
|
+
ensure
|
|
780
|
+
window&.close
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
def render(scene, width:, height:, spp: 1, max_depth: 8, engine: :ruby, workers: 1, seed: 42, progress: nil)
|
|
784
|
+
workers = Integer(workers)
|
|
785
|
+
seed = Integer(seed)
|
|
786
|
+
raise ArgumentError, "workers must be positive" unless workers.positive?
|
|
787
|
+
raise ArgumentError, "dimensions, spp and max_depth must be positive" unless [width, height, spp, max_depth].all? { |value| value.is_a?(Integer) && value.positive? }
|
|
788
|
+
raise ArgumentError, "progress must respond to call" if progress && !progress.respond_to?(:call)
|
|
789
|
+
case engine.to_sym
|
|
790
|
+
when :ruby then Engines::Parallel.render(scene, width: width, height: height, spp: spp, max_depth: max_depth, workers: workers, seed: seed, progress: progress)
|
|
791
|
+
when :rlsl_c then Engines::RlslC.render(scene, width: width, height: height, spp: spp, max_depth: max_depth, seed: seed)
|
|
792
|
+
when :metal then Engines::Metal.render(scene, width: width, height: height, spp: spp, max_depth: max_depth, seed: seed)
|
|
793
|
+
else raise ArgumentError, "unknown engine: #{engine}"
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def benchmark(scene, engines: [:ruby], width: 64, height: 36, spp: 1, max_depth: 4)
|
|
798
|
+
engines.to_h do |engine|
|
|
799
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
800
|
+
render(scene, width: width, height: height, spp: spp, max_depth: max_depth, engine: engine)
|
|
801
|
+
[engine, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started]
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def vec(value)
|
|
806
|
+
value.is_a?(Vec3) ? value : Vec3.new(*value)
|
|
807
|
+
end
|
|
808
|
+
private_class_method :vec
|
|
809
|
+
end
|
data/scenes/shader.rb
ADDED
data/scenes/weekend.rb
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "caustics"
|
|
4
|
+
|
|
5
|
+
SCENE = Caustics::Scene.build do
|
|
6
|
+
camera from: [3, 2, 2], to: [0, 0, -1], fov: 45
|
|
7
|
+
sphere center: [0, -100.5, -1], radius: 100, material: lambertian(checker([0.2, 0.3, 0.1], [0.9, 0.9, 0.9], scale: 0.32))
|
|
8
|
+
sphere center: [0, 0, -1], radius: 0.5, material: lambertian([0.7, 0.3, 0.3])
|
|
9
|
+
sphere center: [-1, 0, -1], radius: 0.5, material: dielectric(1.5)
|
|
10
|
+
sphere center: [1, 0, -1], radius: 0.5, material: metal([0.8, 0.8, 0.8], fuzz: 0.1)
|
|
11
|
+
end
|
data/sig/caustics.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: caustics
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ydah
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: tessel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.1.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.1.0
|
|
26
|
+
description: A deterministic pure Ruby ray tracer used as an rbgfx teaching example.
|
|
27
|
+
email:
|
|
28
|
+
- t.yudai92@gmail.com
|
|
29
|
+
executables:
|
|
30
|
+
- caustics
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE.txt
|
|
36
|
+
- README.md
|
|
37
|
+
- Rakefile
|
|
38
|
+
- docs/spikes.md
|
|
39
|
+
- exe/caustics
|
|
40
|
+
- lib/caustics.rb
|
|
41
|
+
- lib/caustics/version.rb
|
|
42
|
+
- scenes/shader.rb
|
|
43
|
+
- scenes/weekend.rb
|
|
44
|
+
- sig/caustics.rbs
|
|
45
|
+
homepage: https://github.com/rbgfx/caustics
|
|
46
|
+
licenses:
|
|
47
|
+
- MIT
|
|
48
|
+
metadata:
|
|
49
|
+
homepage_uri: https://github.com/rbgfx/caustics
|
|
50
|
+
source_code_uri: https://github.com/rbgfx/caustics/tree/main
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: 3.1.0
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 4.0.16
|
|
66
|
+
specification_version: 4
|
|
67
|
+
summary: A small Ruby path tracer
|
|
68
|
+
test_files: []
|