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,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
|