stagecraft 1.0.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/LICENSE.txt +21 -0
- data/README.md +207 -0
- data/Rakefile +8 -0
- data/docs/api.md +85 -0
- data/docs/tutorial.md +344 -0
- data/examples/01_spinning_cube.rb +13 -0
- data/examples/02_primitives.rb +21 -0
- data/examples/03_pbr_materials.rb +20 -0
- data/examples/04_lights.rb +22 -0
- data/examples/05_directional_shadow.rb +23 -0
- data/examples/06_transparency.rb +19 -0
- data/examples/07_texture.rb +22 -0
- data/examples/08_shader_material.rb +30 -0
- data/examples/09_gltf_loader.rb +24 -0
- data/examples/10_animation.rb +21 -0
- data/examples/11_scene_graph.rb +20 -0
- data/examples/12_orbit_controls.rb +13 -0
- data/examples/13_orthographic_camera.rb +18 -0
- data/examples/14_raycaster.rb +14 -0
- data/examples/15_offscreen.rb +22 -0
- data/examples/16_msaa.rb +13 -0
- data/examples/17_physics_binding.rb +22 -0
- data/examples/18_custom_geometry.rb +22 -0
- data/examples/19_imgui_stats.rb +24 -0
- data/examples/20_window_adapters.rb +15 -0
- data/examples/README.md +11 -0
- data/examples/support/demo.rb +49 -0
- data/lib/stagecraft/animation/action.rb +79 -0
- data/lib/stagecraft/animation/clip.rb +15 -0
- data/lib/stagecraft/animation/mixer.rb +97 -0
- data/lib/stagecraft/animation/track.rb +122 -0
- data/lib/stagecraft/app.rb +61 -0
- data/lib/stagecraft/cameras/camera.rb +15 -0
- data/lib/stagecraft/cameras/orthographic_camera.rb +60 -0
- data/lib/stagecraft/cameras/perspective_camera.rb +69 -0
- data/lib/stagecraft/color.rb +56 -0
- data/lib/stagecraft/controls/orbit_controls.rb +119 -0
- data/lib/stagecraft/core/attribute.rb +91 -0
- data/lib/stagecraft/core/bounding.rb +131 -0
- data/lib/stagecraft/core/geometry.rb +155 -0
- data/lib/stagecraft/core/mesh.rb +26 -0
- data/lib/stagecraft/core/node.rb +163 -0
- data/lib/stagecraft/core/observed_value.rb +182 -0
- data/lib/stagecraft/core/raycaster.rb +62 -0
- data/lib/stagecraft/core/scene.rb +11 -0
- data/lib/stagecraft/core/skin.rb +23 -0
- data/lib/stagecraft/geometries.rb +200 -0
- data/lib/stagecraft/lights/ambient_light.rb +10 -0
- data/lib/stagecraft/lights/directional_light.rb +31 -0
- data/lib/stagecraft/lights/light.rb +30 -0
- data/lib/stagecraft/lights/point_light.rb +23 -0
- data/lib/stagecraft/lights/spot_light.rb +36 -0
- data/lib/stagecraft/loaders/gltf_loader.rb +387 -0
- data/lib/stagecraft/materials/material.rb +58 -0
- data/lib/stagecraft/materials/pbr_material.rb +75 -0
- data/lib/stagecraft/materials/shader_material.rb +21 -0
- data/lib/stagecraft/materials/unlit_material.rb +26 -0
- data/lib/stagecraft/physics_binding.rb +63 -0
- data/lib/stagecraft/renderer/features.rb +58 -0
- data/lib/stagecraft/renderer/frame.rb +45 -0
- data/lib/stagecraft/renderer/frame_resources.rb +467 -0
- data/lib/stagecraft/renderer/gpu_context.rb +120 -0
- data/lib/stagecraft/renderer/pipeline_cache.rb +59 -0
- data/lib/stagecraft/renderer/pipeline_factory.rb +470 -0
- data/lib/stagecraft/renderer/render_list.rb +80 -0
- data/lib/stagecraft/renderer/renderer.rb +336 -0
- data/lib/stagecraft/renderer/resource_cache.rb +176 -0
- data/lib/stagecraft/renderer/shaders/common.wgsl +48 -0
- data/lib/stagecraft/renderer/shaders/pbr.wgsl +251 -0
- data/lib/stagecraft/renderer/shaders/shadow.wgsl +37 -0
- data/lib/stagecraft/renderer/shaders/skinning.wgsl +6 -0
- data/lib/stagecraft/renderer/shaders/tonemap.wgsl +37 -0
- data/lib/stagecraft/renderer/shaders/unlit.wgsl +89 -0
- data/lib/stagecraft/renderer/shaders.rb +64 -0
- data/lib/stagecraft/renderer/stats.rb +41 -0
- data/lib/stagecraft/renderer/uniform_packer.rb +91 -0
- data/lib/stagecraft/renderer/wgpu_compatibility.rb +41 -0
- data/lib/stagecraft/textures/cube_texture.rb +16 -0
- data/lib/stagecraft/textures/sampler_state.rb +58 -0
- data/lib/stagecraft/textures/texture.rb +71 -0
- data/lib/stagecraft/version.rb +5 -0
- data/lib/stagecraft/window/adapter.rb +38 -0
- data/lib/stagecraft/window/glfw.rb +67 -0
- data/lib/stagecraft/window/sdl3.rb +57 -0
- data/lib/stagecraft.rb +60 -0
- metadata +187 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
class Skin
|
|
5
|
+
attr_reader :joints, :inverse_bind_matrices, :skeleton
|
|
6
|
+
|
|
7
|
+
def initialize(joints:, inverse_bind_matrices: nil, skeleton: nil)
|
|
8
|
+
@joints = joints.freeze
|
|
9
|
+
@inverse_bind_matrices = inverse_bind_matrices || Array.new(joints.length) { Larb::Mat4.identity }
|
|
10
|
+
@skeleton = skeleton
|
|
11
|
+
unless @inverse_bind_matrices.length == joints.length
|
|
12
|
+
raise ArgumentError, "inverse bind matrix count must match joint count"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def joint_matrices(mesh)
|
|
17
|
+
mesh_inverse = mesh.world_matrix.inverse
|
|
18
|
+
joints.each_with_index.map do |joint, index|
|
|
19
|
+
inverse_bind_matrices[index] * joint.world_matrix * mesh_inverse
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Geometries
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def box(width = 1.0, height = 1.0, depth = 1.0, **dimensions)
|
|
8
|
+
width = dimensions.delete(:width) || width
|
|
9
|
+
height = dimensions.delete(:height) || height
|
|
10
|
+
depth = dimensions.delete(:depth) || depth
|
|
11
|
+
reject_unknown_options!(dimensions)
|
|
12
|
+
half = [width, height, depth].map { |value| Float(value) / 2.0 }
|
|
13
|
+
faces = [
|
|
14
|
+
[[1, 0, 0], [[half[0], -half[1], half[2]], [half[0], -half[1], -half[2]], [half[0], half[1], -half[2]], [half[0], half[1], half[2]]]],
|
|
15
|
+
[[-1, 0, 0], [[-half[0], -half[1], -half[2]], [-half[0], -half[1], half[2]], [-half[0], half[1], half[2]], [-half[0], half[1], -half[2]]]],
|
|
16
|
+
[[0, 1, 0], [[-half[0], half[1], half[2]], [half[0], half[1], half[2]], [half[0], half[1], -half[2]], [-half[0], half[1], -half[2]]]],
|
|
17
|
+
[[0, -1, 0], [[-half[0], -half[1], -half[2]], [half[0], -half[1], -half[2]], [half[0], -half[1], half[2]], [-half[0], -half[1], half[2]]]],
|
|
18
|
+
[[0, 0, 1], [[-half[0], -half[1], half[2]], [half[0], -half[1], half[2]], [half[0], half[1], half[2]], [-half[0], half[1], half[2]]]],
|
|
19
|
+
[[0, 0, -1], [[half[0], -half[1], -half[2]], [-half[0], -half[1], -half[2]], [-half[0], half[1], -half[2]], [half[0], half[1], -half[2]]]]
|
|
20
|
+
]
|
|
21
|
+
positions = []
|
|
22
|
+
normals = []
|
|
23
|
+
uvs = []
|
|
24
|
+
indices = []
|
|
25
|
+
faces.each_with_index do |(normal, vertices), face_index|
|
|
26
|
+
positions.concat(vertices.flatten)
|
|
27
|
+
normals.concat(normal * 4)
|
|
28
|
+
uvs.concat([0, 0, 1, 0, 1, 1, 0, 1])
|
|
29
|
+
base = face_index * 4
|
|
30
|
+
indices.concat([base, base + 1, base + 2, base, base + 2, base + 3])
|
|
31
|
+
end
|
|
32
|
+
build(positions, normals, uvs, indices)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def plane(width = 1.0, height = 1.0, width_segments: 1, height_segments: 1, **dimensions)
|
|
36
|
+
width = dimensions.delete(:width) || width
|
|
37
|
+
height = dimensions.delete(:height) || height
|
|
38
|
+
reject_unknown_options!(dimensions)
|
|
39
|
+
grid(
|
|
40
|
+
u_segments: width_segments, v_segments: height_segments,
|
|
41
|
+
point: ->(u, v) { [(u - 0.5) * width, (0.5 - v) * height, 0.0] },
|
|
42
|
+
normal: ->(_u, _v) { [0.0, 0.0, 1.0] }
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def sphere(radius = 1.0, width_segments: 32, height_segments: 16, **dimensions)
|
|
47
|
+
radius = dimensions.delete(:radius) || radius
|
|
48
|
+
reject_unknown_options!(dimensions)
|
|
49
|
+
radius = Float(radius)
|
|
50
|
+
grid(
|
|
51
|
+
u_segments: [Integer(width_segments), 3].max,
|
|
52
|
+
v_segments: [Integer(height_segments), 2].max,
|
|
53
|
+
point: lambda { |u, v|
|
|
54
|
+
phi = u * Math::PI * 2.0
|
|
55
|
+
theta = v * Math::PI
|
|
56
|
+
[-(radius * Math.cos(phi) * Math.sin(theta)), radius * Math.cos(theta),
|
|
57
|
+
radius * Math.sin(phi) * Math.sin(theta)]
|
|
58
|
+
},
|
|
59
|
+
normal: lambda { |u, v|
|
|
60
|
+
phi = u * Math::PI * 2.0
|
|
61
|
+
theta = v * Math::PI
|
|
62
|
+
[-Math.cos(phi) * Math.sin(theta), Math.cos(theta), Math.sin(phi) * Math.sin(theta)]
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cylinder(radius_top = 1.0, radius_bottom = 1.0, height = 1.0,
|
|
68
|
+
radial_segments: 32, height_segments: 1, open_ended: false, **dimensions)
|
|
69
|
+
radius_top = dimensions.delete(:radius_top) || radius_top
|
|
70
|
+
radius_bottom = dimensions.delete(:radius_bottom) || radius_bottom
|
|
71
|
+
height = dimensions.delete(:height) || height
|
|
72
|
+
reject_unknown_options!(dimensions)
|
|
73
|
+
radial = [Integer(radial_segments), 3].max
|
|
74
|
+
vertical = [Integer(height_segments), 1].max
|
|
75
|
+
slope = (radius_bottom - radius_top) / height.to_f
|
|
76
|
+
side = grid(
|
|
77
|
+
u_segments: radial,
|
|
78
|
+
v_segments: vertical,
|
|
79
|
+
point: lambda { |u, v|
|
|
80
|
+
theta = u * Math::PI * 2.0
|
|
81
|
+
radius = radius_top + ((radius_bottom - radius_top) * v)
|
|
82
|
+
[radius * Math.sin(theta), (0.5 - v) * height, radius * Math.cos(theta)]
|
|
83
|
+
},
|
|
84
|
+
normal: lambda { |u, _v|
|
|
85
|
+
theta = u * Math::PI * 2.0
|
|
86
|
+
Larb::Vec3.new(Math.sin(theta), slope, Math.cos(theta)).normalize.to_a
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
return side if open_ended
|
|
90
|
+
|
|
91
|
+
caps = []
|
|
92
|
+
caps << disc(radius_top, height / 2.0, radial, up: true) unless radius_top.to_f.zero?
|
|
93
|
+
caps << disc(radius_bottom, -height / 2.0, radial, up: false) unless radius_bottom.to_f.zero?
|
|
94
|
+
merge_geometries(side, *caps)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def torus(radius = 1.0, tube = 0.4, radial_segments: 12, tubular_segments: 48,
|
|
98
|
+
arc: Math::PI * 2.0, **dimensions)
|
|
99
|
+
radius = dimensions.delete(:radius) || radius
|
|
100
|
+
tube = dimensions.delete(:tube) || tube
|
|
101
|
+
reject_unknown_options!(dimensions)
|
|
102
|
+
grid(
|
|
103
|
+
u_segments: [Integer(tubular_segments), 3].max,
|
|
104
|
+
v_segments: [Integer(radial_segments), 3].max,
|
|
105
|
+
point: lambda { |u, v|
|
|
106
|
+
angle = u * arc
|
|
107
|
+
radial_angle = v * Math::PI * 2.0
|
|
108
|
+
distance = radius + (tube * Math.cos(radial_angle))
|
|
109
|
+
[distance * Math.cos(angle), distance * Math.sin(angle), tube * Math.sin(radial_angle)]
|
|
110
|
+
},
|
|
111
|
+
normal: lambda { |u, v|
|
|
112
|
+
angle = u * arc
|
|
113
|
+
radial_angle = v * Math::PI * 2.0
|
|
114
|
+
[Math.cos(angle) * Math.cos(radial_angle), Math.sin(angle) * Math.cos(radial_angle),
|
|
115
|
+
Math.sin(radial_angle)]
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def grid(u_segments:, v_segments:, point:, normal:)
|
|
121
|
+
positions = []
|
|
122
|
+
normals = []
|
|
123
|
+
uvs = []
|
|
124
|
+
indices = []
|
|
125
|
+
(0..v_segments).each do |iy|
|
|
126
|
+
v = iy.to_f / v_segments
|
|
127
|
+
(0..u_segments).each do |ix|
|
|
128
|
+
u = ix.to_f / u_segments
|
|
129
|
+
positions.concat(point.call(u, v))
|
|
130
|
+
normals.concat(normal.call(u, v))
|
|
131
|
+
uvs.concat([u, 1.0 - v])
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
stride = u_segments + 1
|
|
135
|
+
v_segments.times do |iy|
|
|
136
|
+
u_segments.times do |ix|
|
|
137
|
+
a = (iy * stride) + ix
|
|
138
|
+
b = a + stride
|
|
139
|
+
indices.concat([a, b, a + 1, b, b + 1, a + 1])
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
build(positions, normals, uvs, indices)
|
|
143
|
+
end
|
|
144
|
+
private_class_method :grid
|
|
145
|
+
|
|
146
|
+
def disc(radius, y, segments, up:)
|
|
147
|
+
positions = [0.0, y, 0.0]
|
|
148
|
+
normals = [0.0, up ? 1.0 : -1.0, 0.0]
|
|
149
|
+
uvs = [0.5, 0.5]
|
|
150
|
+
(0..segments).each do |index|
|
|
151
|
+
angle = index.to_f / segments * Math::PI * 2.0
|
|
152
|
+
x = radius * Math.sin(angle)
|
|
153
|
+
z = radius * Math.cos(angle)
|
|
154
|
+
positions.concat([x, y, z])
|
|
155
|
+
normals.concat([0.0, up ? 1.0 : -1.0, 0.0])
|
|
156
|
+
uvs.concat([(x / radius + 1.0) / 2.0, (z / radius + 1.0) / 2.0])
|
|
157
|
+
end
|
|
158
|
+
indices = segments.times.flat_map do |index|
|
|
159
|
+
up ? [0, index + 1, index + 2] : [0, index + 2, index + 1]
|
|
160
|
+
end
|
|
161
|
+
build(positions, normals, uvs, indices)
|
|
162
|
+
end
|
|
163
|
+
private_class_method :disc
|
|
164
|
+
|
|
165
|
+
def build(positions, normals, uvs, indices)
|
|
166
|
+
index_format = positions.length / 3 > 65_535 ? :uint32 : :uint16
|
|
167
|
+
Geometry.new
|
|
168
|
+
.set_attribute(:position, data: positions.pack("e*"), format: :float32x3, count: positions.length / 3)
|
|
169
|
+
.set_attribute(:normal, data: normals.pack("e*"), format: :float32x3, count: normals.length / 3)
|
|
170
|
+
.set_attribute(:uv, data: uvs.pack("e*"), format: :float32x2, count: uvs.length / 2)
|
|
171
|
+
.set_index(data: indices.pack(index_format == :uint16 ? "S<*" : "L<*"), format: index_format)
|
|
172
|
+
end
|
|
173
|
+
private_class_method :build
|
|
174
|
+
|
|
175
|
+
def merge_geometries(*geometries)
|
|
176
|
+
positions = []
|
|
177
|
+
normals = []
|
|
178
|
+
uvs = []
|
|
179
|
+
indices = []
|
|
180
|
+
vertex_offset = 0
|
|
181
|
+
geometries.each do |geometry|
|
|
182
|
+
positions.concat(geometry.attribute(:position).data.unpack("e*"))
|
|
183
|
+
normals.concat(geometry.attribute(:normal).data.unpack("e*"))
|
|
184
|
+
uvs.concat(geometry.attribute(:uv).data.unpack("e*"))
|
|
185
|
+
directive = geometry.index.format == :uint16 ? "S<*" : "L<*"
|
|
186
|
+
indices.concat(geometry.index.data.unpack(directive).map { |index| index + vertex_offset })
|
|
187
|
+
vertex_offset += geometry.attribute(:position).count
|
|
188
|
+
end
|
|
189
|
+
build(positions, normals, uvs, indices)
|
|
190
|
+
end
|
|
191
|
+
private_class_method :merge_geometries
|
|
192
|
+
|
|
193
|
+
def reject_unknown_options!(options)
|
|
194
|
+
return if options.empty?
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "unknown geometry options: #{options.keys.join(", ")}"
|
|
197
|
+
end
|
|
198
|
+
private_class_method :reject_unknown_options!
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Lights
|
|
5
|
+
ShadowConfig = Data.define(:map_size, :near, :far, :extent) do
|
|
6
|
+
def initialize(map_size: 2_048, near: 0.1, far: 100.0, extent: 20.0)
|
|
7
|
+
super(
|
|
8
|
+
map_size: Integer(map_size),
|
|
9
|
+
near: Float(near),
|
|
10
|
+
far: Float(far),
|
|
11
|
+
extent: Float(extent)
|
|
12
|
+
)
|
|
13
|
+
raise ArgumentError, "shadow map_size must be positive" unless self.map_size.positive?
|
|
14
|
+
raise ArgumentError, "shadow far must exceed near" unless self.far > self.near
|
|
15
|
+
raise ArgumentError, "shadow extent must be positive" unless self.extent.positive?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Directional < Light
|
|
20
|
+
attr_accessor :shadow
|
|
21
|
+
|
|
22
|
+
def initialize(cast_shadow: false, shadow: ShadowConfig.new, **)
|
|
23
|
+
super(**)
|
|
24
|
+
self.cast_shadow = cast_shadow
|
|
25
|
+
@shadow = shadow
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
DirectionalLight = Directional
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Lights
|
|
5
|
+
class Light < Node
|
|
6
|
+
attr_reader :color, :intensity
|
|
7
|
+
|
|
8
|
+
def initialize(color: Color.new(1.0), intensity: 1.0, **)
|
|
9
|
+
super(**)
|
|
10
|
+
self.color = color
|
|
11
|
+
self.intensity = intensity
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def color=(value)
|
|
15
|
+
@color = Color.coerce(value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def intensity=(value)
|
|
19
|
+
number = Float(value)
|
|
20
|
+
raise ArgumentError, "light intensity must be non-negative" if number.negative?
|
|
21
|
+
|
|
22
|
+
@intensity = number
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def direction
|
|
26
|
+
world_matrix.extract_rotation * Larb::Vec3.forward
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Lights
|
|
5
|
+
class Point < Light
|
|
6
|
+
attr_reader :range
|
|
7
|
+
|
|
8
|
+
def initialize(range: Float::INFINITY, **)
|
|
9
|
+
super(**)
|
|
10
|
+
self.range = range
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def range=(value)
|
|
14
|
+
number = Float(value)
|
|
15
|
+
raise ArgumentError, "light range must be positive" unless number.positive?
|
|
16
|
+
|
|
17
|
+
@range = number
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
PointLight = Point
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stagecraft
|
|
4
|
+
module Lights
|
|
5
|
+
class Spot < Point
|
|
6
|
+
attr_reader :inner_angle, :outer_angle
|
|
7
|
+
|
|
8
|
+
def initialize(inner_angle: 0.0, outer_angle: Math::PI / 4.0, **)
|
|
9
|
+
super(**)
|
|
10
|
+
@inner_angle = Float(inner_angle)
|
|
11
|
+
@outer_angle = Float(outer_angle)
|
|
12
|
+
validate_angles!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def inner_angle=(value)
|
|
16
|
+
@inner_angle = Float(value)
|
|
17
|
+
validate_angles! if defined?(@outer_angle)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def outer_angle=(value)
|
|
21
|
+
@outer_angle = Float(value)
|
|
22
|
+
validate_angles! if defined?(@inner_angle)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_angles!
|
|
28
|
+
valid = inner_angle >= 0.0 && outer_angle > 0.0 &&
|
|
29
|
+
inner_angle <= outer_angle && outer_angle <= (Math::PI / 2.0)
|
|
30
|
+
raise ArgumentError, "spot angles must satisfy 0 <= inner <= outer <= pi/2" unless valid
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
SpotLight = Spot
|
|
35
|
+
end
|
|
36
|
+
end
|