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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Scene < Node
5
+ attr_reader :background
6
+
7
+ def background=(value)
8
+ @background = value.nil? ? nil : Color.coerce(value)
9
+ end
10
+ end
11
+ end
@@ -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,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Lights
5
+ class Ambient < Light
6
+ end
7
+
8
+ AmbientLight = Ambient
9
+ end
10
+ 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