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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +207 -0
  4. data/Rakefile +8 -0
  5. data/docs/api.md +85 -0
  6. data/docs/tutorial.md +344 -0
  7. data/examples/01_spinning_cube.rb +13 -0
  8. data/examples/02_primitives.rb +21 -0
  9. data/examples/03_pbr_materials.rb +20 -0
  10. data/examples/04_lights.rb +22 -0
  11. data/examples/05_directional_shadow.rb +23 -0
  12. data/examples/06_transparency.rb +19 -0
  13. data/examples/07_texture.rb +22 -0
  14. data/examples/08_shader_material.rb +30 -0
  15. data/examples/09_gltf_loader.rb +24 -0
  16. data/examples/10_animation.rb +21 -0
  17. data/examples/11_scene_graph.rb +20 -0
  18. data/examples/12_orbit_controls.rb +13 -0
  19. data/examples/13_orthographic_camera.rb +18 -0
  20. data/examples/14_raycaster.rb +14 -0
  21. data/examples/15_offscreen.rb +22 -0
  22. data/examples/16_msaa.rb +13 -0
  23. data/examples/17_physics_binding.rb +22 -0
  24. data/examples/18_custom_geometry.rb +22 -0
  25. data/examples/19_imgui_stats.rb +24 -0
  26. data/examples/20_window_adapters.rb +15 -0
  27. data/examples/README.md +11 -0
  28. data/examples/support/demo.rb +49 -0
  29. data/lib/stagecraft/animation/action.rb +79 -0
  30. data/lib/stagecraft/animation/clip.rb +15 -0
  31. data/lib/stagecraft/animation/mixer.rb +97 -0
  32. data/lib/stagecraft/animation/track.rb +122 -0
  33. data/lib/stagecraft/app.rb +61 -0
  34. data/lib/stagecraft/cameras/camera.rb +15 -0
  35. data/lib/stagecraft/cameras/orthographic_camera.rb +60 -0
  36. data/lib/stagecraft/cameras/perspective_camera.rb +69 -0
  37. data/lib/stagecraft/color.rb +56 -0
  38. data/lib/stagecraft/controls/orbit_controls.rb +119 -0
  39. data/lib/stagecraft/core/attribute.rb +91 -0
  40. data/lib/stagecraft/core/bounding.rb +131 -0
  41. data/lib/stagecraft/core/geometry.rb +155 -0
  42. data/lib/stagecraft/core/mesh.rb +26 -0
  43. data/lib/stagecraft/core/node.rb +163 -0
  44. data/lib/stagecraft/core/observed_value.rb +182 -0
  45. data/lib/stagecraft/core/raycaster.rb +62 -0
  46. data/lib/stagecraft/core/scene.rb +11 -0
  47. data/lib/stagecraft/core/skin.rb +23 -0
  48. data/lib/stagecraft/geometries.rb +200 -0
  49. data/lib/stagecraft/lights/ambient_light.rb +10 -0
  50. data/lib/stagecraft/lights/directional_light.rb +31 -0
  51. data/lib/stagecraft/lights/light.rb +30 -0
  52. data/lib/stagecraft/lights/point_light.rb +23 -0
  53. data/lib/stagecraft/lights/spot_light.rb +36 -0
  54. data/lib/stagecraft/loaders/gltf_loader.rb +387 -0
  55. data/lib/stagecraft/materials/material.rb +58 -0
  56. data/lib/stagecraft/materials/pbr_material.rb +75 -0
  57. data/lib/stagecraft/materials/shader_material.rb +21 -0
  58. data/lib/stagecraft/materials/unlit_material.rb +26 -0
  59. data/lib/stagecraft/physics_binding.rb +63 -0
  60. data/lib/stagecraft/renderer/features.rb +58 -0
  61. data/lib/stagecraft/renderer/frame.rb +45 -0
  62. data/lib/stagecraft/renderer/frame_resources.rb +467 -0
  63. data/lib/stagecraft/renderer/gpu_context.rb +120 -0
  64. data/lib/stagecraft/renderer/pipeline_cache.rb +59 -0
  65. data/lib/stagecraft/renderer/pipeline_factory.rb +470 -0
  66. data/lib/stagecraft/renderer/render_list.rb +80 -0
  67. data/lib/stagecraft/renderer/renderer.rb +336 -0
  68. data/lib/stagecraft/renderer/resource_cache.rb +176 -0
  69. data/lib/stagecraft/renderer/shaders/common.wgsl +48 -0
  70. data/lib/stagecraft/renderer/shaders/pbr.wgsl +251 -0
  71. data/lib/stagecraft/renderer/shaders/shadow.wgsl +37 -0
  72. data/lib/stagecraft/renderer/shaders/skinning.wgsl +6 -0
  73. data/lib/stagecraft/renderer/shaders/tonemap.wgsl +37 -0
  74. data/lib/stagecraft/renderer/shaders/unlit.wgsl +89 -0
  75. data/lib/stagecraft/renderer/shaders.rb +64 -0
  76. data/lib/stagecraft/renderer/stats.rb +41 -0
  77. data/lib/stagecraft/renderer/uniform_packer.rb +91 -0
  78. data/lib/stagecraft/renderer/wgpu_compatibility.rb +41 -0
  79. data/lib/stagecraft/textures/cube_texture.rb +16 -0
  80. data/lib/stagecraft/textures/sampler_state.rb +58 -0
  81. data/lib/stagecraft/textures/texture.rb +71 -0
  82. data/lib/stagecraft/version.rb +5 -0
  83. data/lib/stagecraft/window/adapter.rb +38 -0
  84. data/lib/stagecraft/window/glfw.rb +67 -0
  85. data/lib/stagecraft/window/sdl3.rb +57 -0
  86. data/lib/stagecraft.rb +60 -0
  87. metadata +187 -0
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Bounding
5
+ class Box3
6
+ attr_reader :min, :max
7
+
8
+ def initialize(min = nil, max = nil)
9
+ @min = min || Larb::Vec3.new(Float::INFINITY, Float::INFINITY, Float::INFINITY)
10
+ @max = max || Larb::Vec3.new(-Float::INFINITY, -Float::INFINITY, -Float::INFINITY)
11
+ end
12
+
13
+ def expand_by_point(point)
14
+ @min = min.min(point)
15
+ @max = max.max(point)
16
+ self
17
+ end
18
+
19
+ def empty?
20
+ min.x > max.x || min.y > max.y || min.z > max.z
21
+ end
22
+
23
+ def center
24
+ return Larb::Vec3.new if empty?
25
+
26
+ (min + max) * 0.5
27
+ end
28
+
29
+ def size
30
+ empty? ? Larb::Vec3.new : max - min
31
+ end
32
+
33
+ def intersects_ray?(origin, inverse_direction)
34
+ tmin = -Float::INFINITY
35
+ tmax = Float::INFINITY
36
+ 3.times do |axis|
37
+ first = (min[axis] - origin[axis]) * inverse_direction[axis]
38
+ second = (max[axis] - origin[axis]) * inverse_direction[axis]
39
+ first, second = second, first if first > second
40
+ tmin = [tmin, first].max
41
+ tmax = [tmax, second].min
42
+ return false if tmax < [tmin, 0.0].max
43
+ end
44
+ true
45
+ end
46
+ end
47
+
48
+ class Sphere
49
+ attr_reader :center, :radius
50
+
51
+ def initialize(center = Larb::Vec3.new, radius = 0.0)
52
+ @center = center
53
+ @radius = radius.to_f
54
+ end
55
+
56
+ def transform(matrix)
57
+ elements = matrix.to_a
58
+ scale = [
59
+ Math.sqrt(elements[0]**2 + elements[1]**2 + elements[2]**2),
60
+ Math.sqrt(elements[4]**2 + elements[5]**2 + elements[6]**2),
61
+ Math.sqrt(elements[8]**2 + elements[9]**2 + elements[10]**2)
62
+ ].max
63
+ transformed = matrix * center.to_vec4(1.0)
64
+ self.class.new(transformed.xyz, radius * scale)
65
+ end
66
+
67
+ def intersects_ray?(origin, direction)
68
+ offset = center - origin
69
+ projected = offset.dot(direction)
70
+ distance_squared = offset.length_squared - (projected * projected)
71
+ return nil if distance_squared > radius * radius
72
+
73
+ half_chord = Math.sqrt((radius * radius) - distance_squared)
74
+ near = projected - half_chord
75
+ far = projected + half_chord
76
+ far.negative? ? nil : [near, 0.0].max
77
+ end
78
+ end
79
+
80
+ class Plane
81
+ attr_reader :normal, :constant
82
+
83
+ def initialize(normal, constant)
84
+ length = normal.length
85
+ raise ArgumentError, "plane normal cannot be zero" if length.zero?
86
+
87
+ @normal = normal / length
88
+ @constant = constant.to_f / length
89
+ end
90
+
91
+ def distance_to_point(point)
92
+ normal.dot(point) + constant
93
+ end
94
+
95
+ def intersects_sphere?(sphere)
96
+ distance_to_point(sphere.center) >= -sphere.radius
97
+ end
98
+ end
99
+
100
+ class Frustum
101
+ attr_reader :planes
102
+
103
+ def initialize(planes)
104
+ raise ArgumentError, "frustum requires six planes" unless planes.length == 6
105
+
106
+ @planes = planes.freeze
107
+ end
108
+
109
+ def self.from_matrix(matrix)
110
+ m = matrix.to_a
111
+ row = ->(index) { [m[index], m[index + 4], m[index + 8], m[index + 12]] }
112
+ r0, r1, r2, r3 = 4.times.map { |index| row.call(index) }
113
+ coefficients = [
114
+ combine(r3, r0, 1), combine(r3, r0, -1),
115
+ combine(r3, r1, 1), combine(r3, r1, -1),
116
+ r2, combine(r3, r2, -1)
117
+ ]
118
+ new(coefficients.map { |a, b, c, d| Plane.new(Larb::Vec3.new(a, b, c), d) })
119
+ end
120
+
121
+ def intersects_sphere?(sphere)
122
+ planes.all? { |plane| plane.intersects_sphere?(sphere) }
123
+ end
124
+
125
+ def self.combine(left, right, sign)
126
+ 4.times.map { |index| left[index] + (right[index] * sign) }
127
+ end
128
+ private_class_method :combine
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Geometry
5
+ ATTRIBUTE_NAMES = %i[position normal uv uv1 tangent color joints weights].freeze
6
+
7
+ TOPOLOGIES = %i[point_list line_list line_strip triangle_list triangle_strip].freeze
8
+
9
+ attr_reader :index, :version, :topology
10
+
11
+ def initialize(topology: :triangle_list)
12
+ @attributes = {}
13
+ @index = nil
14
+ @version = 0
15
+ @disposed = false
16
+ @dispose_callbacks = []
17
+ @bounding_version = -1
18
+ self.topology = topology
19
+ end
20
+
21
+ def set_attribute(name, data:, format:, count:)
22
+ ensure_alive!
23
+ key = name.to_sym
24
+ raise ArgumentError, "unsupported attribute #{name.inspect}" unless ATTRIBUTE_NAMES.include?(key)
25
+
26
+ @attributes[key] = Attribute.new(data:, format:, count:) { changed! }
27
+ changed!
28
+ self
29
+ end
30
+
31
+ def delete_attribute(name)
32
+ return nil unless @attributes.delete(name.to_sym)
33
+
34
+ changed!
35
+ end
36
+
37
+ def attribute(name)
38
+ @attributes[name.to_sym]
39
+ end
40
+
41
+ def attributes
42
+ @attributes.dup.freeze
43
+ end
44
+
45
+ def set_index(data:, format:)
46
+ ensure_alive!
47
+ @index = IndexAttribute.new(data:, format:) { changed! }
48
+ changed!
49
+ self
50
+ end
51
+
52
+ def topology=(value)
53
+ next_value = value.to_sym
54
+ raise ArgumentError, "unsupported topology #{value.inspect}" unless TOPOLOGIES.include?(next_value)
55
+ return next_value if @topology == next_value
56
+
57
+ @topology = next_value
58
+ changed!
59
+ next_value
60
+ end
61
+
62
+ def bounding_box
63
+ refresh_bounds! if @bounding_version != version
64
+ @bounding_box
65
+ end
66
+
67
+ def bounding_sphere
68
+ refresh_bounds! if @bounding_version != version
69
+ @bounding_sphere
70
+ end
71
+
72
+ def compute_normals!
73
+ positions = position_values
74
+ normals = Array.new(positions.length) { Larb::Vec3.new }
75
+ triangle_indices.each_slice(3) do |a, b, c|
76
+ break unless c
77
+
78
+ face = (positions[b] - positions[a]).cross(positions[c] - positions[a])
79
+ normals[a] = normals[a] + face
80
+ normals[b] = normals[b] + face
81
+ normals[c] = normals[c] + face
82
+ end
83
+ packed = normals.flat_map { |normal| normal.length.zero? ? [0.0, 1.0, 0.0] : normal.normalize.to_a }.pack("e*")
84
+ set_attribute(:normal, data: packed, format: :float32x3, count: positions.length)
85
+ end
86
+
87
+ def on_dispose(&block)
88
+ @dispose_callbacks << block
89
+ self
90
+ end
91
+
92
+ def dispose
93
+ return self if @disposed
94
+
95
+ @disposed = true
96
+ @version += 1
97
+ @dispose_callbacks.each { |callback| callback.call(self) }
98
+ @dispose_callbacks.clear
99
+ self
100
+ end
101
+
102
+ def disposed?
103
+ @disposed
104
+ end
105
+
106
+ private
107
+
108
+ def ensure_alive!
109
+ raise DisposedError, "geometry has been disposed" if disposed?
110
+ end
111
+
112
+ def changed!
113
+ @version += 1
114
+ @bounding_version = -1
115
+ self
116
+ end
117
+
118
+ def position_values
119
+ position = attribute(:position)
120
+ raise Error, "geometry has no position attribute" unless position
121
+ raise Error, "bounding and normals require :float32x3 positions" unless position.format == :float32x3
122
+
123
+ position.data.unpack("e*").each_slice(3).map { |values| Larb::Vec3.new(*values) }
124
+ end
125
+
126
+ def triangle_indices
127
+ values = if index
128
+ index.data.unpack(index.format == :uint16 ? "S<*" : "L<*")
129
+ else
130
+ (0...position_values.length).to_a
131
+ end
132
+ return values if topology == :triangle_list
133
+ unless topology == :triangle_strip
134
+ raise Error, "normal computation requires triangle geometry, got #{topology}"
135
+ end
136
+ return [] if values.length < 3
137
+
138
+ (0..(values.length - 3)).flat_map do |offset|
139
+ a, b, c = values.slice(offset, 3)
140
+ offset.odd? ? [b, a, c] : [a, b, c]
141
+ end
142
+ end
143
+
144
+ def refresh_bounds!
145
+ positions = position_values
146
+ box = Bounding::Box3.new
147
+ positions.each { |point| box.expand_by_point(point) }
148
+ center = box.center
149
+ radius = positions.map { |point| point.distance(center) }.max || 0.0
150
+ @bounding_box = box
151
+ @bounding_sphere = Bounding::Sphere.new(center, radius)
152
+ @bounding_version = version
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Mesh < Node
5
+ attr_reader :geometry, :material
6
+ attr_accessor :frustum_culled, :skin, :morph_weights
7
+
8
+ def initialize(geometry, material, **)
9
+ super(**)
10
+ self.geometry = geometry
11
+ self.material = material
12
+ @frustum_culled = true
13
+ @morph_weights = []
14
+ end
15
+
16
+ def geometry=(value)
17
+ raise ArgumentError, "expected a Stagecraft::Geometry" unless value.is_a?(Geometry)
18
+
19
+ @geometry = value
20
+ end
21
+
22
+ def material=(value)
23
+ @material = value
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Node
5
+ attr_reader :position, :rotation, :scale, :children, :parent, :world_version
6
+ attr_accessor :name, :visible, :cast_shadow, :receive_shadow, :render_order
7
+
8
+ def initialize(name: nil)
9
+ @name = name
10
+ @visible = true
11
+ @cast_shadow = false
12
+ @receive_shadow = true
13
+ @render_order = 0
14
+ @children = []
15
+ @parent = nil
16
+ @local_revision = 0
17
+ @cached_local_revision = -1
18
+ @cached_parent_version = nil
19
+ @cached_parent_identity = nil
20
+ @world_version = 0
21
+ @position = ObservedVec3.new(self)
22
+ @rotation = ObservedQuat.new(self)
23
+ @scale = ObservedVec3.new(self, Larb::Vec3.one)
24
+ @local_matrix = Larb::Mat4.identity
25
+ @world_matrix = Larb::Mat4.identity
26
+ end
27
+
28
+ def add(*nodes)
29
+ nodes.flatten.each do |node|
30
+ validate_child!(node)
31
+ next if node.parent.equal?(self)
32
+
33
+ node.parent&.remove(node)
34
+ node.send(:parent=, self)
35
+ children << node
36
+ end
37
+ self
38
+ end
39
+
40
+ def remove(node)
41
+ return nil unless children.delete(node)
42
+
43
+ node.send(:parent=, nil)
44
+ node
45
+ end
46
+
47
+ def traverse(&block)
48
+ return enum_for(:traverse) unless block
49
+
50
+ block.call(self)
51
+ children.each { |child| child.traverse(&block) }
52
+ self
53
+ end
54
+
55
+ def find(target_name)
56
+ traverse.find { |node| node.name == target_name }
57
+ end
58
+
59
+ def local_matrix
60
+ return @local_matrix if @cached_local_revision == @local_revision
61
+
62
+ @local_matrix = compose_local_matrix
63
+ @cached_local_revision = @local_revision
64
+ @local_matrix
65
+ end
66
+
67
+ def world_matrix
68
+ world_matrix_with_version.first
69
+ end
70
+
71
+ def world_matrix_with_version
72
+ parent_matrix, parent_version = parent&.world_matrix_with_version
73
+ parent_identity = parent&.object_id
74
+ stale = @cached_local_revision != @local_revision ||
75
+ @cached_parent_version != parent_version ||
76
+ @cached_parent_identity != parent_identity
77
+ if stale
78
+ local = local_matrix
79
+ @world_matrix = parent_matrix ? parent_matrix * local : local
80
+ @cached_parent_version = parent_version
81
+ @cached_parent_identity = parent_identity
82
+ @world_version += 1
83
+ end
84
+ [@world_matrix, @world_version]
85
+ end
86
+
87
+ def world_position
88
+ world_matrix.extract_translation
89
+ end
90
+
91
+ def look_at(target, up: Larb::Vec3.up)
92
+ target_value = target.respond_to?(:to_larb) ? target.to_larb : target
93
+ eye = world_position
94
+ raise ArgumentError, "look_at target must differ from position" if eye == target_value
95
+
96
+ world_rotation = Larb::Mat4.look_at(eye, target_value, up).inverse.extract_rotation
97
+ local_rotation = if parent
98
+ parent.world_matrix.extract_rotation.inverse * world_rotation
99
+ else
100
+ world_rotation
101
+ end
102
+ rotation.set(local_rotation)
103
+ self
104
+ end
105
+
106
+ def transform_dirty!
107
+ @local_revision += 1
108
+ self
109
+ end
110
+
111
+ protected
112
+
113
+ def compose_local_matrix
114
+ Larb::Mat4.translation(*position.to_a) *
115
+ rotation.to_larb.to_mat4 *
116
+ Larb::Mat4.scaling(*scale.to_a)
117
+ end
118
+
119
+ private
120
+
121
+ def parent=(value)
122
+ @parent = value
123
+ @cached_parent_identity = nil
124
+ @cached_parent_version = nil
125
+ end
126
+
127
+ def validate_child!(node)
128
+ raise ArgumentError, "expected a Stagecraft::Node" unless node.is_a?(Node)
129
+ raise ArgumentError, "a node cannot parent itself" if node.equal?(self)
130
+ raise ArgumentError, "adding this node would create a cycle" if ancestor_of?(node)
131
+ end
132
+
133
+ def ancestor_of?(node)
134
+ current = self
135
+ while current
136
+ return true if current.equal?(node)
137
+
138
+ current = current.parent
139
+ end
140
+ false
141
+ end
142
+ end
143
+
144
+ class MatrixNode < Node
145
+ attr_reader :matrix
146
+
147
+ def initialize(matrix, **)
148
+ super(**)
149
+ self.matrix = matrix
150
+ end
151
+
152
+ def matrix=(value)
153
+ @matrix = value.is_a?(Larb::Mat4) ? value : Larb::Mat4.new(value.to_a)
154
+ transform_dirty!
155
+ end
156
+
157
+ protected
158
+
159
+ def compose_local_matrix
160
+ matrix
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class ObservedVec3
5
+ COMPONENTS = %i[x y z].freeze
6
+
7
+ def initialize(owner, value = Larb::Vec3.new)
8
+ @owner = owner
9
+ @value = coerce(value)
10
+ end
11
+
12
+ COMPONENTS.each_with_index do |component, index|
13
+ define_method(component) { @value.public_send(component) }
14
+ define_method(:"#{component}=") do |new_value|
15
+ return new_value if @value.public_send(component) == new_value.to_f
16
+
17
+ @value.public_send(:"#{component}=", new_value.to_f)
18
+ changed!
19
+ new_value
20
+ end
21
+ end
22
+
23
+ def set(x, y = nil, z = nil)
24
+ values = y.nil? && z.nil? ? vector_components(x) : [x, y, z]
25
+ return self if to_a == values.map(&:to_f)
26
+
27
+ @value = Larb::Vec3.new(*values)
28
+ changed!
29
+ self
30
+ end
31
+
32
+ def []=(index, value)
33
+ component = COMPONENTS.fetch(index)
34
+ public_send(:"#{component}=", value)
35
+ end
36
+
37
+ def [](index)
38
+ @value[index]
39
+ end
40
+
41
+ def normalize!
42
+ set(@value.normalize)
43
+ end
44
+
45
+ def to_larb
46
+ Larb::Vec3.new(*to_a)
47
+ end
48
+
49
+ def to_a
50
+ @value.to_a
51
+ end
52
+
53
+ def ==(other)
54
+ other.respond_to?(:to_a) && to_a == other.to_a
55
+ end
56
+
57
+ def inspect
58
+ "#{self.class.name}[#{to_a.join(", ")}]"
59
+ end
60
+
61
+ def method_missing(name, *arguments, **keywords, &block)
62
+ return super unless @value.respond_to?(name)
63
+
64
+ @value.public_send(name, *unwrap_arguments(*arguments), **keywords, &block)
65
+ end
66
+
67
+ def respond_to_missing?(name, include_private = false)
68
+ @value.respond_to?(name, include_private) || super
69
+ end
70
+
71
+ private
72
+
73
+ def changed!
74
+ @owner.transform_dirty!
75
+ end
76
+
77
+ def coerce(value)
78
+ Larb::Vec3.new(*vector_components(value))
79
+ end
80
+
81
+ def vector_components(value)
82
+ return value.to_a if value.respond_to?(:to_a)
83
+
84
+ Array(value)
85
+ end
86
+
87
+ def unwrap_arguments(*arguments)
88
+ arguments.map { |argument| argument.respond_to?(:to_larb) ? argument.to_larb : argument }
89
+ end
90
+ end
91
+
92
+ class ObservedQuat
93
+ COMPONENTS = %i[x y z w].freeze
94
+
95
+ def initialize(owner, value = Larb::Quat.new)
96
+ @owner = owner
97
+ @value = coerce(value)
98
+ end
99
+
100
+ COMPONENTS.each_with_index do |component, index|
101
+ define_method(component) { @value.public_send(component) }
102
+ define_method(:"#{component}=") do |new_value|
103
+ return new_value if @value[index] == new_value.to_f
104
+
105
+ @value.public_send(:"#{component}=", new_value.to_f)
106
+ changed!
107
+ new_value
108
+ end
109
+ end
110
+
111
+ def set(x, y = nil, z = nil, w = nil)
112
+ values = y.nil? && z.nil? && w.nil? ? x.to_a : [x, y, z, w]
113
+ next_value = Larb::Quat.new(*values).normalize
114
+ return self if @value == next_value
115
+
116
+ @value = next_value
117
+ changed!
118
+ self
119
+ end
120
+
121
+ def rotate_x!(angle)
122
+ rotate_axis!(Larb::Vec3.right, angle)
123
+ end
124
+
125
+ def rotate_y!(angle)
126
+ rotate_axis!(Larb::Vec3.up, angle)
127
+ end
128
+
129
+ def rotate_z!(angle)
130
+ rotate_axis!(Larb::Vec3.back, angle)
131
+ end
132
+
133
+ def slerp!(target, amount)
134
+ set(@value.slerp(coerce(target), amount))
135
+ end
136
+
137
+ def normalize!
138
+ set(@value.normalize)
139
+ end
140
+
141
+ def to_larb
142
+ Larb::Quat.new(*to_a)
143
+ end
144
+
145
+ def to_a
146
+ @value.to_a
147
+ end
148
+
149
+ def ==(other)
150
+ other.respond_to?(:to_a) && to_a == other.to_a
151
+ end
152
+
153
+ def inspect
154
+ "#{self.class.name}[#{to_a.join(", ")}]"
155
+ end
156
+
157
+ def method_missing(name, *arguments, **keywords, &block)
158
+ return super unless @value.respond_to?(name)
159
+
160
+ values = arguments.map { |argument| argument.respond_to?(:to_larb) ? argument.to_larb : argument }
161
+ @value.public_send(name, *values, **keywords, &block)
162
+ end
163
+
164
+ def respond_to_missing?(name, include_private = false)
165
+ @value.respond_to?(name, include_private) || super
166
+ end
167
+
168
+ private
169
+
170
+ def rotate_axis!(axis, angle)
171
+ set(@value * Larb::Quat.from_axis_angle(axis, angle.to_f))
172
+ end
173
+
174
+ def changed!
175
+ @owner.transform_dirty!
176
+ end
177
+
178
+ def coerce(value)
179
+ value.is_a?(Larb::Quat) ? value : Larb::Quat.new(*value.to_a)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Raycaster
5
+ Hit = Data.define(:object, :distance, :point)
6
+
7
+ attr_reader :origin, :direction, :near, :far
8
+
9
+ def initialize(origin: Larb::Vec3.new, direction: Larb::Vec3.forward, near: 0.0, far: Float::INFINITY)
10
+ @near = Float(near)
11
+ @far = Float(far)
12
+ raise ArgumentError, "ray near distance cannot be negative" if @near.negative?
13
+ raise ArgumentError, "ray far distance must not be less than near" if @far < @near
14
+
15
+ set(origin, direction)
16
+ end
17
+
18
+ def set(origin, direction)
19
+ @origin = vector(origin)
20
+ value = vector(direction)
21
+ raise ArgumentError, "ray direction cannot be zero" if value.length.zero?
22
+
23
+ @direction = value.normalize
24
+ self
25
+ end
26
+
27
+ def set_from_camera(ndc_x, ndc_y, camera)
28
+ inverse = camera.view_projection_matrix.inverse
29
+ near_point = unproject(inverse, ndc_x, ndc_y, 0.0)
30
+ far_point = unproject(inverse, ndc_x, ndc_y, 1.0)
31
+ ray_origin = camera.is_a?(Cameras::Perspective) ? camera.world_position : near_point
32
+ set(ray_origin, far_point - ray_origin)
33
+ end
34
+
35
+ def intersect(root, recursive: true)
36
+ nodes = recursive ? root.traverse : [root]
37
+ nodes.filter_map { |node| intersect_mesh(node) }.sort_by(&:distance)
38
+ end
39
+
40
+ private
41
+
42
+ def unproject(matrix, x, y, z)
43
+ value = matrix * Larb::Vec4.new(x, y, z, 1.0)
44
+ value.perspective_divide
45
+ end
46
+
47
+ def vector(value)
48
+ source = value.respond_to?(:to_larb) ? value.to_larb : value
49
+ Larb::Vec3.new(*source.to_a)
50
+ end
51
+
52
+ def intersect_mesh(node)
53
+ return unless node.is_a?(Mesh) && node.visible
54
+
55
+ sphere = node.geometry.bounding_sphere.transform(node.world_matrix)
56
+ distance = sphere.intersects_ray?(origin, direction)
57
+ return unless distance && distance >= near && distance <= far
58
+
59
+ Hit.new(object: node, distance:, point: origin + (direction * distance))
60
+ end
61
+ end
62
+ end