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,387 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Loaders
5
+ class GLTF
6
+ Result = Data.define(:scene, :scenes, :animations, :cameras)
7
+
8
+ ATTRIBUTE_NAMES = {
9
+ POSITION: :position,
10
+ NORMAL: :normal,
11
+ TEXCOORD_0: :uv,
12
+ TEXCOORD_1: :uv1,
13
+ TANGENT: :tangent,
14
+ COLOR_0: :color,
15
+ JOINTS_0: :joints,
16
+ WEIGHTS_0: :weights
17
+ }.freeze
18
+ TOPOLOGIES = {
19
+ points: :point_list,
20
+ lines: :line_list,
21
+ line_strip: :line_strip,
22
+ triangles: :triangle_list,
23
+ triangle_strip: :triangle_strip
24
+ }.freeze
25
+
26
+ def self.load(source)
27
+ require "rgltf"
28
+ require "texel"
29
+
30
+ document = source.is_a?(Rgltf::Document) ? source : Rgltf.load(source)
31
+ new(document).convert
32
+ end
33
+
34
+ def initialize(document)
35
+ @document = document
36
+ @textures = {}
37
+ @materials = {}
38
+ end
39
+
40
+ def convert
41
+ source_scenes = @document.scenes
42
+ converted = source_scenes.map { |scene| convert_scene(scene) }
43
+ default_index = @document.default_scene && source_scenes.index(@document.default_scene)
44
+ default_index ||= 0
45
+ default_scene = converted[default_index] || convert_scene(nil)
46
+ mapping = @scene_mappings.fetch(default_scene.object_id)
47
+ Result.new(
48
+ scene: default_scene,
49
+ scenes: converted.empty? ? [default_scene] : converted,
50
+ animations: convert_animations(mapping),
51
+ cameras: converted_cameras(mapping)
52
+ )
53
+ end
54
+
55
+ private
56
+
57
+ def convert_scene(source_scene)
58
+ @scene_mappings ||= {}
59
+ mapping = {}
60
+ stage_scene = Scene.new(name: source_scene&.name)
61
+ @document.nodes.each { |node| build_node(node, mapping) }
62
+ roots = source_scene ? source_scene.nodes : inferred_roots
63
+ roots.each { |root| stage_scene.add(mapping.fetch(root)) }
64
+ @document.nodes.each { |node| decorate_node(node, mapping) }
65
+ @scene_mappings[stage_scene.object_id] = mapping
66
+ stage_scene
67
+ end
68
+
69
+ def inferred_roots
70
+ children = @document.nodes.flat_map(&:children)
71
+ @document.nodes.reject { |node| children.include?(node) }
72
+ end
73
+
74
+ def build_node(source, mapping)
75
+ return mapping[source] if mapping.key?(source)
76
+
77
+ node = transform_node(source)
78
+ mapping[source] = node
79
+ source.children.each { |child| node.add(build_node(child, mapping)) }
80
+ node
81
+ end
82
+
83
+ def transform_node(source)
84
+ return trs_node(source) if source.trs?
85
+
86
+ matrix = Larb::Mat4.new(source.matrix)
87
+ position = matrix.extract_translation
88
+ rotation = matrix.extract_rotation
89
+ scale = matrix.extract_scale
90
+ recomposed = Larb::Mat4.translation(*position.to_a) *
91
+ rotation.to_mat4 *
92
+ Larb::Mat4.scaling(*scale.to_a)
93
+ return MatrixNode.new(matrix, name: source.name) unless matrix.near?(recomposed)
94
+
95
+ Node.new(name: source.name).tap do |node|
96
+ node.position.set(position)
97
+ node.rotation.set(rotation)
98
+ node.scale.set(scale)
99
+ end
100
+ rescue StandardError
101
+ MatrixNode.new(matrix, name: source.name)
102
+ end
103
+
104
+ def trs_node(source)
105
+ Node.new(name: source.name).tap do |node|
106
+ node.position.set(source.translation)
107
+ node.rotation.set(source.rotation)
108
+ node.scale.set(source.scale)
109
+ end
110
+ end
111
+
112
+ def decorate_node(source, mapping)
113
+ target = mapping.fetch(source)
114
+ source.mesh&.primitives&.each_with_index do |primitive, index|
115
+ target.add(convert_primitive(source, primitive, index, mapping))
116
+ end
117
+ target.add(convert_camera(source.camera)) if source.camera
118
+ light = source.extension("KHR_lights_punctual")
119
+ target.add(convert_light(light.light)) if light
120
+ end
121
+
122
+ def convert_primitive(source_node, primitive, index, mapping)
123
+ geometry = convert_geometry(primitive)
124
+ material = convert_material(primitive.material)
125
+ name = source_node.mesh.name
126
+ name = "#{source_node.name || name || "mesh"} primitive #{index}" if source_node.mesh.primitives.length > 1
127
+ Mesh.new(geometry, material, name:).tap do |mesh|
128
+ weights = source_node.weights || source_node.mesh.weights
129
+ mesh.morph_weights = weights.dup if weights
130
+ mesh.skin = convert_skin(source_node.skin, mapping) if source_node.skin
131
+ end
132
+ end
133
+
134
+ def convert_geometry(primitive)
135
+ topology = TOPOLOGIES.fetch(primitive.mode, :triangle_list)
136
+ geometry = Geometry.new(topology:)
137
+ primitive.attributes.each do |semantic, accessor|
138
+ name = ATTRIBUTE_NAMES[semantic]
139
+ next unless name
140
+
141
+ data, format = attribute_payload(semantic, accessor)
142
+ raise Error, "glTF attribute #{semantic} has no WebGPU vertex format" unless format
143
+
144
+ geometry.set_attribute(name, data:, format:, count: accessor.count)
145
+ end
146
+ raise Error, "glTF primitive has no POSITION attribute" unless geometry.attribute(:position)
147
+ if %i[line_loop triangle_fan].include?(primitive.mode)
148
+ geometry.topology = primitive.mode == :line_loop ? :line_list : :triangle_list
149
+ geometry.set_index(data: expanded_indices(primitive), format: :uint32)
150
+ elsif primitive.indices
151
+ data, format = index_data(primitive.indices)
152
+ geometry.set_index(data:, format:)
153
+ end
154
+ geometry
155
+ end
156
+
157
+ def attribute_payload(semantic, accessor)
158
+ return expanded_color(accessor) if semantic == :COLOR_0 &&
159
+ accessor.type == "VEC3" &&
160
+ accessor.vertex_format.nil?
161
+
162
+ [accessor.packed, accessor.vertex_format]
163
+ end
164
+
165
+ def expanded_color(accessor)
166
+ case accessor.component_type
167
+ when :u8
168
+ values = accessor.packed.unpack("C*").each_slice(3).flat_map { |color| [*color, 255] }
169
+ [values.pack("C*"), :unorm8x4]
170
+ when :u16
171
+ values = accessor.packed.unpack("S<*").each_slice(3).flat_map { |color| [*color, 65_535] }
172
+ [values.pack("S<*"), :unorm16x4]
173
+ else
174
+ [accessor.packed, accessor.vertex_format]
175
+ end
176
+ end
177
+
178
+ def expanded_indices(primitive)
179
+ values = if primitive.indices
180
+ primitive.indices.to_a.map { |value| value.is_a?(Array) ? value.first : value }
181
+ else
182
+ (0...primitive.attributes.fetch(:POSITION).count).to_a
183
+ end
184
+ expanded = if primitive.mode == :line_loop
185
+ return "".b if values.empty?
186
+
187
+ values.each_with_index.flat_map { |value, index| [value, values[(index + 1) % values.length]] }
188
+ else
189
+ (1...(values.length - 1)).flat_map { |index| [values.first, values[index], values[index + 1]] }
190
+ end
191
+ expanded.pack("L<*")
192
+ end
193
+
194
+ def index_data(accessor)
195
+ case accessor.component_type
196
+ when :u8 then [accessor.packed_as_u32, :uint32]
197
+ when :u16 then [accessor.packed, :uint16]
198
+ when :u32 then [accessor.packed, :uint32]
199
+ else raise Error, "unsupported glTF index component #{accessor.component_type}"
200
+ end
201
+ end
202
+
203
+ def convert_material(source)
204
+ return Materials::PBR.new unless source
205
+
206
+ @materials[source] ||= begin
207
+ pbr = source.pbr
208
+ common = {
209
+ side: source.double_sided ? :double : :front,
210
+ alpha_mode: source.alpha_mode,
211
+ alpha_cutoff: source.alpha_cutoff,
212
+ transparent: source.alpha_mode == :blend
213
+ }
214
+ if source.unlit?
215
+ Materials::Unlit.new(
216
+ **common,
217
+ color: pbr.base_color_factor,
218
+ map: texture_from_info(pbr.base_color_texture, :srgb),
219
+ uv_transform: uv_transform(pbr.base_color_texture),
220
+ uv_set: uv_set(pbr.base_color_texture)
221
+ )
222
+ else
223
+ Materials::PBR.new(
224
+ **common,
225
+ base_color: pbr.base_color_factor,
226
+ base_color_map: texture_from_info(pbr.base_color_texture, :srgb),
227
+ base_color_uv_transform: uv_transform(pbr.base_color_texture),
228
+ base_color_uv_set: uv_set(pbr.base_color_texture),
229
+ metallic: pbr.metallic_factor,
230
+ roughness: pbr.roughness_factor,
231
+ metallic_roughness_map: texture_from_info(pbr.metallic_roughness_texture, :linear),
232
+ metallic_roughness_uv_transform: uv_transform(pbr.metallic_roughness_texture),
233
+ metallic_roughness_uv_set: uv_set(pbr.metallic_roughness_texture),
234
+ normal_map: texture_from_info(source.normal_texture, :linear),
235
+ normal_scale: source.normal_texture&.scale || 1.0,
236
+ normal_uv_transform: uv_transform(source.normal_texture),
237
+ normal_uv_set: uv_set(source.normal_texture),
238
+ occlusion_map: texture_from_info(source.occlusion_texture, :linear),
239
+ occlusion_strength: source.occlusion_texture&.strength || 1.0,
240
+ occlusion_uv_transform: uv_transform(source.occlusion_texture),
241
+ occlusion_uv_set: uv_set(source.occlusion_texture),
242
+ emissive: source.emissive_factor,
243
+ emissive_map: texture_from_info(source.emissive_texture, :srgb),
244
+ emissive_strength: source.extension("KHR_materials_emissive_strength")&.emissive_strength || 1.0,
245
+ emissive_uv_transform: uv_transform(source.emissive_texture),
246
+ emissive_uv_set: uv_set(source.emissive_texture)
247
+ )
248
+ end
249
+ end
250
+ end
251
+
252
+ def texture_from_info(info, color_space)
253
+ return unless info
254
+
255
+ source = info.texture
256
+ @textures[[source, color_space]] ||= begin
257
+ raise Error, "glTF texture has no image source" unless source.source
258
+
259
+ image = Texel.load(source.source.bytes, channels: 4, color_space:)
260
+ Textures::Texture.new(image, sampler: convert_sampler(source.sampler), color_space:)
261
+ end
262
+ end
263
+
264
+ def convert_sampler(source)
265
+ return Textures::SamplerState.new unless source
266
+
267
+ min_filter, mipmap_filter = split_min_filter(source.min_filter)
268
+ Textures::SamplerState.new(
269
+ wrap_u: source.wrap_s,
270
+ wrap_v: source.wrap_t,
271
+ mag_filter: source.mag_filter || :linear,
272
+ min_filter:,
273
+ mipmap_filter:
274
+ )
275
+ end
276
+
277
+ def split_min_filter(filter)
278
+ {
279
+ nil => %i[linear linear],
280
+ nearest: %i[nearest nearest],
281
+ linear: %i[linear linear],
282
+ nearest_mipmap_nearest: %i[nearest nearest],
283
+ linear_mipmap_nearest: %i[linear nearest],
284
+ nearest_mipmap_linear: %i[nearest linear],
285
+ linear_mipmap_linear: %i[linear linear]
286
+ }.fetch(filter)
287
+ end
288
+
289
+ def uv_transform(info)
290
+ matrix = info&.transform&.uv_matrix
291
+ matrix ? Larb::Mat3.new(matrix) : Larb::Mat3.new
292
+ end
293
+
294
+ def uv_set(info)
295
+ return 0 unless info
296
+
297
+ Integer(info.transform&.tex_coord || info.tex_coord)
298
+ end
299
+
300
+ def convert_skin(source, mapping)
301
+ matrices = if source.inverse_bind_matrices
302
+ source.inverse_bind_matrices.to_a.map { |values| Larb::Mat4.new(values) }
303
+ end
304
+ Skin.new(
305
+ joints: source.joints.map { |joint| mapping.fetch(joint) },
306
+ inverse_bind_matrices: matrices,
307
+ skeleton: source.skeleton && mapping.fetch(source.skeleton)
308
+ )
309
+ end
310
+
311
+ def convert_camera(source)
312
+ camera = case source.type
313
+ when :perspective
314
+ value = source.perspective
315
+ Cameras::Perspective.new(
316
+ fov: value.yfov * 180.0 / Math::PI,
317
+ aspect: value.aspect_ratio || 1.0,
318
+ near: value.znear,
319
+ far: value.zfar || 1.0e9
320
+ )
321
+ when :orthographic
322
+ value = source.orthographic
323
+ Cameras::Orthographic.new(
324
+ left: -value.xmag,
325
+ right: value.xmag,
326
+ top: value.ymag,
327
+ bottom: -value.ymag,
328
+ near: value.znear,
329
+ far: value.zfar
330
+ )
331
+ end
332
+ camera.name = source.name
333
+ camera
334
+ end
335
+
336
+ def convert_light(source)
337
+ options = { name: source.name, color: [*source.color, 1.0], intensity: source.intensity }
338
+ case source.type
339
+ when :directional then Lights::Directional.new(**options)
340
+ when :point then Lights::Point.new(**options, range: source.range || Float::INFINITY)
341
+ when :spot
342
+ Lights::Spot.new(
343
+ **options,
344
+ range: source.range || Float::INFINITY,
345
+ inner_angle: source.spot.inner_cone_angle,
346
+ outer_angle: source.spot.outer_cone_angle
347
+ )
348
+ end
349
+ end
350
+
351
+ def convert_animations(mapping)
352
+ @document.animations.map do |animation|
353
+ tracks = animation.channels.filter_map do |channel|
354
+ next unless channel.target_node
355
+
356
+ sampler = channel.sampler
357
+ Animation::Track.new(
358
+ times: sampler.input.packed,
359
+ values: sampler.output.packed,
360
+ target: mapping.fetch(channel.target_node),
361
+ target_path: channel.target_path,
362
+ interpolation: sampler.interpolation,
363
+ value_size: animation_value_size(channel)
364
+ )
365
+ end
366
+ Animation::Clip.new(name: animation.name, tracks:)
367
+ end
368
+ end
369
+
370
+ def animation_value_size(channel)
371
+ return unless channel.target_path == :weights
372
+
373
+ sampler = channel.sampler
374
+ multiplier = sampler.interpolation == :cubicspline ? 3 : 1
375
+ sampler.output.count / (sampler.input.count * multiplier)
376
+ end
377
+
378
+ def converted_cameras(mapping)
379
+ @document.nodes.filter_map do |node|
380
+ next unless node.camera
381
+
382
+ mapping.fetch(node).children.find { |child| child.is_a?(Cameras::Camera) }
383
+ end
384
+ end
385
+ end
386
+ end
387
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Materials
5
+ class Base
6
+ SIDES = %i[front back double].freeze
7
+ BLENDS = %i[normal additive multiply].freeze
8
+ ALPHA_MODES = %i[opaque mask blend].freeze
9
+
10
+ attr_reader :version
11
+
12
+ def initialize(transparent: false, opacity: 1.0, side: :front, depth_write: true,
13
+ depth_test: true, blend: :normal, alpha_mode: :opaque, alpha_cutoff: 0.5)
14
+ @version = 0
15
+ self.transparent = transparent
16
+ self.opacity = opacity
17
+ self.side = side
18
+ self.depth_write = depth_write
19
+ self.depth_test = depth_test
20
+ self.blend = blend
21
+ self.alpha_mode = alpha_mode
22
+ self.alpha_cutoff = alpha_cutoff
23
+ end
24
+
25
+ def self.versioned_attribute(name, coerce: nil, validate: nil)
26
+ attr_reader name
27
+ define_method(:"#{name}=") do |value|
28
+ next_value = coerce ? coerce.call(value) : value
29
+ if validate && !validate.call(next_value)
30
+ raise ArgumentError, "invalid #{name}: #{value.inspect}"
31
+ end
32
+ return next_value if instance_variable_defined?(:"@#{name}") &&
33
+ instance_variable_get(:"@#{name}") == next_value
34
+
35
+ instance_variable_set(:"@#{name}", next_value)
36
+ @version += 1
37
+ next_value
38
+ end
39
+ end
40
+
41
+ versioned_attribute :transparent, coerce: ->(value) { !!value }
42
+ versioned_attribute :opacity, coerce: ->(value) { Float(value) },
43
+ validate: ->(value) { value.between?(0.0, 1.0) }
44
+ versioned_attribute :side, coerce: :to_sym.to_proc, validate: ->(value) { SIDES.include?(value) }
45
+ versioned_attribute :depth_write, coerce: ->(value) { !!value }
46
+ versioned_attribute :depth_test, coerce: ->(value) { !!value }
47
+ versioned_attribute :blend, validate: ->(value) { value.is_a?(Hash) || BLENDS.include?(value.to_sym) }
48
+ versioned_attribute :alpha_mode, coerce: :to_sym.to_proc,
49
+ validate: ->(value) { ALPHA_MODES.include?(value) }
50
+ versioned_attribute :alpha_cutoff, coerce: ->(value) { Float(value) },
51
+ validate: ->(value) { value.between?(0.0, 1.0) }
52
+
53
+ def transparent?
54
+ transparent || opacity < 1.0 || alpha_mode == :blend
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Materials
5
+ class PBR < Base
6
+ TEXTURE_ATTRIBUTES = %i[
7
+ base_color_map metallic_roughness_map normal_map occlusion_map emissive_map
8
+ ].freeze
9
+ TRANSFORM_ATTRIBUTES = %i[
10
+ base_color_uv_transform metallic_roughness_uv_transform normal_uv_transform
11
+ occlusion_uv_transform emissive_uv_transform
12
+ ].freeze
13
+ UV_SET_ATTRIBUTES = %i[
14
+ base_color_uv_set metallic_roughness_uv_set normal_uv_set
15
+ occlusion_uv_set emissive_uv_set
16
+ ].freeze
17
+
18
+ def initialize(base_color: Color.new(1.0), base_color_map: nil, metallic: 1.0,
19
+ roughness: 1.0, metallic_roughness_map: nil, normal_map: nil,
20
+ normal_scale: 1.0, occlusion_map: nil, occlusion_strength: 1.0,
21
+ emissive: Color.new(0.0), emissive_map: nil, emissive_strength: 1.0,
22
+ base_color_uv_transform: Larb::Mat3.new,
23
+ metallic_roughness_uv_transform: Larb::Mat3.new,
24
+ normal_uv_transform: Larb::Mat3.new,
25
+ occlusion_uv_transform: Larb::Mat3.new,
26
+ emissive_uv_transform: Larb::Mat3.new,
27
+ base_color_uv_set: 0, metallic_roughness_uv_set: 0,
28
+ normal_uv_set: 0, occlusion_uv_set: 0, emissive_uv_set: 0, **)
29
+ super(**)
30
+ self.base_color = base_color
31
+ self.base_color_map = base_color_map
32
+ self.metallic = metallic
33
+ self.roughness = roughness
34
+ self.metallic_roughness_map = metallic_roughness_map
35
+ self.normal_map = normal_map
36
+ self.normal_scale = normal_scale
37
+ self.occlusion_map = occlusion_map
38
+ self.occlusion_strength = occlusion_strength
39
+ self.emissive = emissive
40
+ self.emissive_map = emissive_map
41
+ self.emissive_strength = emissive_strength
42
+ TRANSFORM_ATTRIBUTES.each do |name|
43
+ self.public_send(:"#{name}=", binding.local_variable_get(name))
44
+ end
45
+ UV_SET_ATTRIBUTES.each do |name|
46
+ self.public_send(:"#{name}=", binding.local_variable_get(name))
47
+ end
48
+ end
49
+
50
+ versioned_attribute :base_color, coerce: Color.method(:coerce)
51
+ versioned_attribute :metallic, coerce: ->(value) { Float(value) },
52
+ validate: ->(value) { value.between?(0.0, 1.0) }
53
+ versioned_attribute :roughness, coerce: ->(value) { Float(value) },
54
+ validate: ->(value) { value.between?(0.0, 1.0) }
55
+ versioned_attribute :normal_scale, coerce: ->(value) { Float(value) }
56
+ versioned_attribute :occlusion_strength, coerce: ->(value) { Float(value) },
57
+ validate: ->(value) { value.between?(0.0, 1.0) }
58
+ versioned_attribute :emissive, coerce: Color.method(:coerce)
59
+ versioned_attribute :emissive_strength, coerce: ->(value) { Float(value) },
60
+ validate: ->(value) { value >= 0.0 }
61
+ TEXTURE_ATTRIBUTES.each { |name| versioned_attribute(name) }
62
+ TRANSFORM_ATTRIBUTES.each do |name|
63
+ versioned_attribute name, coerce: lambda { |value|
64
+ value.is_a?(Larb::Mat3) ? value : Larb::Mat3.new(value.to_a)
65
+ }
66
+ end
67
+ UV_SET_ATTRIBUTES.each do |name|
68
+ versioned_attribute name, coerce: ->(value) { Integer(value) },
69
+ validate: ->(value) { value.between?(0, 1) }
70
+ end
71
+ end
72
+
73
+ PBRMaterial = PBR
74
+ end
75
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Materials
5
+ class Shader < Base
6
+ def initialize(wgsl:, uniforms: {}, **)
7
+ super(**)
8
+ self.wgsl = wgsl
9
+ self.uniforms = uniforms
10
+ end
11
+
12
+ versioned_attribute :wgsl, coerce: lambda { |value|
13
+ source = value.respond_to?(:to_wgsl) ? value.to_wgsl : value
14
+ String(source).dup.freeze
15
+ }
16
+ versioned_attribute :uniforms, coerce: ->(value) { value.transform_keys(&:to_sym).freeze }
17
+ end
18
+
19
+ ShaderMaterial = Shader
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Materials
5
+ class Unlit < Base
6
+ def initialize(color: Color.new(1.0), map: nil, uv_transform: Larb::Mat3.new,
7
+ uv_set: 0, **)
8
+ super(**)
9
+ self.color = color
10
+ self.map = map
11
+ self.uv_transform = uv_transform
12
+ self.uv_set = uv_set
13
+ end
14
+
15
+ versioned_attribute :color, coerce: Color.method(:coerce)
16
+ versioned_attribute :map
17
+ versioned_attribute :uv_transform, coerce: lambda { |value|
18
+ value.is_a?(Larb::Mat3) ? value : Larb::Mat3.new(value.to_a)
19
+ }
20
+ versioned_attribute :uv_set, coerce: ->(value) { Integer(value) },
21
+ validate: ->(value) { value.between?(0, 1) }
22
+ end
23
+
24
+ UnlitMaterial = Unlit
25
+ end
26
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class PhysicsBinding
5
+ Entry = Data.define(:node, :body, :previous_position, :previous_rotation)
6
+
7
+ def initialize
8
+ @entries = []
9
+ end
10
+
11
+ def bind(node, body)
12
+ raise ArgumentError, "physics binding requires a Stagecraft::Node" unless node.is_a?(Node)
13
+
14
+ validate_body!(body)
15
+ unbind(node)
16
+ @entries << Entry.new(
17
+ node:,
18
+ body:,
19
+ previous_position: vector(body.position),
20
+ previous_rotation: quaternion(body.rotation)
21
+ )
22
+ self
23
+ end
24
+
25
+ def unbind(node)
26
+ @entries.reject! { |entry| entry.node.equal?(node) }
27
+ self
28
+ end
29
+
30
+ def sync!(alpha = 1.0)
31
+ amount = Float(alpha).clamp(0.0, 1.0)
32
+ @entries.map! do |entry|
33
+ current_position = vector(entry.body.position)
34
+ current_rotation = quaternion(entry.body.rotation)
35
+ entry.node.position.set(entry.previous_position.lerp(current_position, amount))
36
+ entry.node.rotation.set(entry.previous_rotation.slerp(current_rotation, amount))
37
+ Entry.new(
38
+ node: entry.node,
39
+ body: entry.body,
40
+ previous_position: current_position,
41
+ previous_rotation: current_rotation
42
+ )
43
+ end
44
+ self
45
+ end
46
+
47
+ private
48
+
49
+ def validate_body!(body)
50
+ return if body.respond_to?(:position) && body.respond_to?(:rotation)
51
+
52
+ raise ArgumentError, "physics body must expose position and rotation"
53
+ end
54
+
55
+ def vector(value)
56
+ Larb::Vec3.new(*value.to_a)
57
+ end
58
+
59
+ def quaternion(value)
60
+ Larb::Quat.new(*value.to_a)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Stagecraft
6
+ class Renderer
7
+ module Features
8
+ ORDER = %i[
9
+ HAS_UV HAS_NORMAL HAS_TANGENT HAS_VERTEX_COLOR HAS_BASECOLOR_MAP HAS_MR_MAP
10
+ HAS_NORMAL_MAP HAS_OCCLUSION_MAP HAS_EMISSIVE_MAP SKINNED ALPHA_MASK DOUBLE_SIDED
11
+ ].freeze
12
+ ATTRIBUTE_FEATURES = {
13
+ uv: :HAS_UV,
14
+ uv1: :HAS_UV1,
15
+ normal: :HAS_NORMAL,
16
+ tangent: :HAS_TANGENT,
17
+ color: :HAS_VERTEX_COLOR
18
+ }.freeze
19
+
20
+ module_function
21
+
22
+ def for(mesh)
23
+ values = Set.new
24
+ ATTRIBUTE_FEATURES.each { |attribute, feature| values << feature if mesh.geometry.attribute(attribute) }
25
+ color = mesh.geometry.attribute(:color)
26
+ values << :COLOR_VEC3 if color && color.components == 3
27
+ material_features(mesh.material, values)
28
+ values << :SKINNED if mesh.skin
29
+ values.freeze
30
+ end
31
+
32
+ def bits(features)
33
+ ORDER.each_with_index.sum { |feature, index| features.include?(feature) ? (1 << index) : 0 }
34
+ end
35
+
36
+ def vertex_layout_id(geometry)
37
+ [
38
+ geometry.topology,
39
+ geometry.index&.format,
40
+ geometry.attributes.sort_by(&:first).map { |name, attribute| [name, attribute.format] }
41
+ ].hash
42
+ end
43
+
44
+ def material_features(material, values)
45
+ values << :ALPHA_MASK if material.alpha_mode == :mask
46
+ values << :DOUBLE_SIDED if material.side == :double
47
+ return unless material.is_a?(Materials::PBR)
48
+
49
+ values << :HAS_BASECOLOR_MAP if material.base_color_map
50
+ values << :HAS_MR_MAP if material.metallic_roughness_map
51
+ values << :HAS_NORMAL_MAP if material.normal_map
52
+ values << :HAS_OCCLUSION_MAP if material.occlusion_map
53
+ values << :HAS_EMISSIVE_MAP if material.emissive_map
54
+ end
55
+ private_class_method :material_features
56
+ end
57
+ end
58
+ end