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 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
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
+ [![Gem version](https://badge.fury.io/rb/caustics.svg)](https://rubygems.org/gems/caustics)
4
+ [![Downloads](https://img.shields.io/gem/dt/caustics?label=downloads)](https://rubygems.org/gems/caustics)
5
+ [![CI](https://github.com/rbgfx/caustics/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/caustics/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) { |task| task.ruby_opts = ["-I../tessel/lib", "-I../rlsl/lib"] }
7
+
8
+ task default: :spec
9
+ task verify: :spec
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Caustics
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "caustics"
4
+
5
+ SCENE = Caustics::Scene.build do
6
+ camera from: [0, 0, 3], to: [0, 0, 0], fov: 45
7
+ sphere center: [0, 0, 0], radius: 0.75, material: lambertian([0.8, 0.2, 0.2])
8
+ end
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
@@ -0,0 +1,4 @@
1
+ module Caustics
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []