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,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Animation
5
+ class Track
6
+ PATH_COMPONENTS = { translation: 3, rotation: 4, scale: 3 }.freeze
7
+ INTERPOLATIONS = %i[linear step cubicspline].freeze
8
+
9
+ attr_reader :times, :values, :target, :target_path, :interpolation, :value_size
10
+
11
+ def initialize(times:, values:, target:, target_path:, interpolation: :linear, value_size: nil)
12
+ @times = unpack(times)
13
+ @values = unpack(values)
14
+ @target = target
15
+ @target_path = target_path.to_sym
16
+ @interpolation = interpolation.to_sym
17
+ raise ArgumentError, "unsupported interpolation #{interpolation.inspect}" unless INTERPOLATIONS.include?(@interpolation)
18
+ raise ArgumentError, "animation times cannot be empty" if @times.empty?
19
+ raise ArgumentError, "animation times must be sorted" unless @times.each_cons(2).all? { |left, right| left <= right }
20
+
21
+ @value_size = value_size || infer_value_size
22
+ expected = @times.length * @value_size * (cubicspline? ? 3 : 1)
23
+ raise ArgumentError, "animation value count #{values_count} does not match expected #{expected}" unless values_count == expected
24
+ end
25
+
26
+ def duration
27
+ times.last
28
+ end
29
+
30
+ def sample(time, out = nil)
31
+ value = sample_value(Float(time))
32
+ assign(out, value) if out
33
+ value
34
+ end
35
+
36
+ private
37
+
38
+ def unpack(value)
39
+ value.is_a?(String) ? value.unpack("e*") : value.to_a.map(&:to_f)
40
+ end
41
+
42
+ def infer_value_size
43
+ PATH_COMPONENTS[target_path] || begin
44
+ divisor = times.length * (cubicspline? ? 3 : 1)
45
+ values.length / divisor
46
+ end
47
+ end
48
+
49
+ def values_count
50
+ values.length
51
+ end
52
+
53
+ def cubicspline?
54
+ interpolation == :cubicspline
55
+ end
56
+
57
+ def sample_value(time)
58
+ return key_value(0) if time <= times.first
59
+ return key_value(times.length - 1) if time >= times.last
60
+
61
+ upper = times.bsearch_index { |key_time| key_time > time }
62
+ lower = upper - 1
63
+ duration = times[upper] - times[lower]
64
+ amount = duration.zero? ? 0.0 : (time - times[lower]) / duration
65
+ case interpolation
66
+ when :step then key_value(lower)
67
+ when :cubicspline then cubic_value(lower, upper, amount, duration)
68
+ else linear_value(lower, upper, amount)
69
+ end
70
+ end
71
+
72
+ def key_value(index)
73
+ value_index = cubicspline? ? ((index * 3) + 1) : index
74
+ slice(value_index)
75
+ end
76
+
77
+ def linear_value(lower, upper, amount)
78
+ first = key_value(lower)
79
+ second = key_value(upper)
80
+ return quaternion(first).slerp(quaternion(second), amount).to_a if target_path == :rotation
81
+
82
+ first.zip(second).map { |left, right| left + ((right - left) * amount) }
83
+ end
84
+
85
+ def cubic_value(lower, upper, amount, duration)
86
+ p0 = slice((lower * 3) + 1)
87
+ m0 = slice((lower * 3) + 2)
88
+ p1 = slice((upper * 3) + 1)
89
+ m1 = slice(upper * 3)
90
+ t2 = amount * amount
91
+ t3 = t2 * amount
92
+ h00 = (2 * t3) - (3 * t2) + 1
93
+ h10 = t3 - (2 * t2) + amount
94
+ h01 = (-2 * t3) + (3 * t2)
95
+ h11 = t3 - t2
96
+ result = value_size.times.map do |index|
97
+ (h00 * p0[index]) + (h10 * duration * m0[index]) +
98
+ (h01 * p1[index]) + (h11 * duration * m1[index])
99
+ end
100
+ target_path == :rotation ? quaternion(result).normalize.to_a : result
101
+ end
102
+
103
+ def slice(index)
104
+ values.slice(index * value_size, value_size)
105
+ end
106
+
107
+ def quaternion(value)
108
+ Larb::Quat.new(*value)
109
+ end
110
+
111
+ def assign(out, value)
112
+ if out.respond_to?(:set)
113
+ out.set(*value)
114
+ elsif out.respond_to?(:replace)
115
+ out.replace(value)
116
+ else
117
+ raise ArgumentError, "animation output must support #set or #replace"
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class App
5
+ attr_reader :window, :renderer
6
+
7
+ def initialize(title: "Stagecraft", width: 800, height: 600, msaa: 4,
8
+ window: :sdl3, renderer: nil, resizable: true)
9
+ @window = build_window(window, title:, width:, height:, resizable:)
10
+ @renderer = renderer || Renderer.new(window: @window, width:, height:, msaa:)
11
+ rescue StandardError
12
+ @window&.close
13
+ raise
14
+ end
15
+
16
+ def aspect
17
+ width, height = window.drawable_size
18
+ height.zero? ? 1.0 : width.to_f / height
19
+ end
20
+
21
+ def run
22
+ raise ArgumentError, "App#run requires a block" unless block_given?
23
+
24
+ previous = monotonic_time
25
+ loop do
26
+ window.poll_events
27
+ break if window.should_close?
28
+
29
+ now = monotonic_time
30
+ dt = (now - previous).clamp(0.0, 0.1)
31
+ previous = now
32
+ yield(dt)
33
+ break if ENV["STAGECRAFT_SMOKE"] == "1"
34
+ end
35
+ self
36
+ ensure
37
+ begin
38
+ renderer.dispose
39
+ ensure
40
+ window.close
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def build_window(value, **options)
47
+ return value unless value.is_a?(Symbol) || value.is_a?(String)
48
+
49
+ case value.to_sym
50
+ when :sdl3 then Window::Sdl3.new(**options)
51
+ when :glfw then Window::Glfw.new(**options)
52
+ else
53
+ raise ArgumentError, "unknown window adapter #{value.inspect}"
54
+ end
55
+ end
56
+
57
+ def monotonic_time
58
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Cameras
5
+ class Camera < Node
6
+ def view_matrix
7
+ world_matrix.inverse
8
+ end
9
+
10
+ def view_projection_matrix
11
+ projection_matrix * view_matrix
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Cameras
5
+ class Orthographic < Camera
6
+ PROPERTIES = %i[left right top bottom near far].freeze
7
+
8
+ attr_reader(*PROPERTIES)
9
+
10
+ def initialize(left: -1.0, right: 1.0, top: 1.0, bottom: -1.0, near: 0.1, far: 2_000.0, **)
11
+ super(**)
12
+ @projection_revision = 0
13
+ @cached_projection_revision = -1
14
+ { left:, right:, top:, bottom:, near:, far: }.each do |name, value|
15
+ public_send(:"#{name}=", value)
16
+ end
17
+ validate_extents!
18
+ end
19
+
20
+ PROPERTIES.each do |property|
21
+ define_method(:"#{property}=") do |value|
22
+ number = Float(value)
23
+ variable = :"@#{property}"
24
+ return number if instance_variable_defined?(variable) && instance_variable_get(variable) == number
25
+
26
+ instance_variable_set(variable, number)
27
+ @projection_revision += 1
28
+ number
29
+ end
30
+ end
31
+
32
+ def projection_matrix
33
+ return @projection_matrix if @cached_projection_revision == @projection_revision
34
+
35
+ validate_extents!
36
+ width = right - left
37
+ height = top - bottom
38
+ depth = far - near
39
+ @projection_matrix = Larb::Mat4.new([
40
+ 2.0 / width, 0.0, 0.0, 0.0,
41
+ 0.0, 2.0 / height, 0.0, 0.0,
42
+ 0.0, 0.0, -1.0 / depth, 0.0,
43
+ -(right + left) / width, -(top + bottom) / height, -near / depth, 1.0
44
+ ])
45
+ @cached_projection_revision = @projection_revision
46
+ @projection_matrix
47
+ end
48
+
49
+ private
50
+
51
+ def validate_extents!
52
+ raise ArgumentError, "left and right must differ" if left == right
53
+ raise ArgumentError, "top and bottom must differ" if top == bottom
54
+ raise ArgumentError, "far must be greater than near" unless far > near
55
+ end
56
+ end
57
+
58
+ OrthographicCamera = Orthographic
59
+ end
60
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Cameras
5
+ class Perspective < Camera
6
+ attr_reader :fov, :aspect, :near, :far
7
+
8
+ def initialize(fov: 50.0, aspect: 1.0, near: 0.1, far: 2_000.0, **)
9
+ super(**)
10
+ @projection_revision = 0
11
+ @cached_projection_revision = -1
12
+ self.fov = fov
13
+ self.aspect = aspect
14
+ self.near = near
15
+ self.far = far
16
+ end
17
+
18
+ def fov=(value)
19
+ assign_projection_value(:@fov, Float(value)) { |number| number.positive? && number < 180.0 }
20
+ end
21
+
22
+ def aspect=(value)
23
+ assign_projection_value(:@aspect, Float(value), &:positive?)
24
+ end
25
+
26
+ def near=(value)
27
+ assign_projection_value(:@near, Float(value), &:positive?)
28
+ validate_planes! if defined?(@far)
29
+ end
30
+
31
+ def far=(value)
32
+ assign_projection_value(:@far, Float(value), &:positive?)
33
+ validate_planes! if defined?(@near)
34
+ end
35
+
36
+ def projection_matrix
37
+ return @projection_matrix if @cached_projection_revision == @projection_revision
38
+
39
+ radians = fov * Math::PI / 180.0
40
+ f = 1.0 / Math.tan(radians / 2.0)
41
+ @projection_matrix = Larb::Mat4.new([
42
+ f / aspect, 0.0, 0.0, 0.0,
43
+ 0.0, f, 0.0, 0.0,
44
+ 0.0, 0.0, far / (near - far), -1.0,
45
+ 0.0, 0.0, (near * far) / (near - far), 0.0
46
+ ])
47
+ @cached_projection_revision = @projection_revision
48
+ @projection_matrix
49
+ end
50
+
51
+ private
52
+
53
+ def assign_projection_value(variable, value)
54
+ raise ArgumentError, "invalid projection value #{value}" unless yield(value)
55
+ return value if instance_variable_defined?(variable) && instance_variable_get(variable) == value
56
+
57
+ instance_variable_set(variable, value)
58
+ @projection_revision += 1
59
+ value
60
+ end
61
+
62
+ def validate_planes!
63
+ raise ArgumentError, "far must be greater than near" unless far > near
64
+ end
65
+ end
66
+
67
+ PerspectiveCamera = Perspective
68
+ end
69
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Color
5
+ HEX_PATTERN = /\A#?([0-9a-f]{6})([0-9a-f]{2})?\z/i
6
+
7
+ attr_reader :r, :g, :b, :a
8
+
9
+ def initialize(value = 0.0, green = nil, blue = nil, alpha = 1.0)
10
+ components = normalize(value, green, blue, alpha)
11
+ @r, @g, @b, @a = components.map(&:to_f)
12
+ end
13
+
14
+ def self.coerce(value)
15
+ value.is_a?(self) ? value : new(value)
16
+ end
17
+
18
+ def to_a
19
+ [r, g, b, a]
20
+ end
21
+
22
+ def to_larb
23
+ Larb::Color.new(r, g, b, a)
24
+ end
25
+
26
+ def ==(other)
27
+ other.respond_to?(:to_a) && to_a == other.to_a
28
+ end
29
+
30
+ private
31
+
32
+ def normalize(value, green, blue, alpha)
33
+ return from_hex(value) if value.is_a?(String)
34
+ return normalize_array(value) if value.respond_to?(:to_ary)
35
+ return normalize_array(value.to_a) if green.nil? && value.respond_to?(:to_a)
36
+ return [value, value, value, alpha] if green.nil? && blue.nil?
37
+
38
+ [value, green, blue, alpha]
39
+ end
40
+
41
+ def from_hex(value)
42
+ match = HEX_PATTERN.match(value)
43
+ raise ArgumentError, "invalid color #{value.inspect}" unless match
44
+
45
+ rgb = match[1].scan(/../).map { |part| part.to_i(16) / 255.0 }
46
+ rgb << (match[2] ? match[2].to_i(16) / 255.0 : 1.0)
47
+ end
48
+
49
+ def normalize_array(value)
50
+ values = value.to_a
51
+ raise ArgumentError, "color requires 3 or 4 components" unless [3, 4].include?(values.length)
52
+
53
+ values.length == 3 ? [*values, 1.0] : values
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ module Controls
5
+ class Orbit
6
+ attr_reader :camera, :target
7
+ attr_accessor :enabled, :rotate_speed, :zoom_speed, :pan_speed, :damping,
8
+ :min_distance, :max_distance, :min_polar_angle, :max_polar_angle
9
+
10
+ def initialize(camera, window = nil, target: Larb::Vec3.new)
11
+ @camera = camera
12
+ @target = vector(target)
13
+ @enabled = true
14
+ @rotate_speed = 0.005
15
+ @zoom_speed = 0.15
16
+ @pan_speed = 0.002
17
+ @damping = 12.0
18
+ @min_distance = 0.01
19
+ @max_distance = Float::INFINITY
20
+ @min_polar_angle = 1e-4
21
+ @max_polar_angle = Math::PI - 1e-4
22
+ @azimuth_delta = 0.0
23
+ @polar_delta = 0.0
24
+ @zoom_delta = 0.0
25
+ @pan_delta = Larb::Vec3.new
26
+ @dragging = false
27
+ sync_spherical
28
+ attach(window) if window
29
+ end
30
+
31
+ def target=(value)
32
+ @target = vector(value)
33
+ sync_spherical
34
+ end
35
+
36
+ def rotate(delta_x, delta_y)
37
+ return self unless enabled
38
+
39
+ @azimuth_delta -= Float(delta_x) * rotate_speed
40
+ @polar_delta -= Float(delta_y) * rotate_speed
41
+ self
42
+ end
43
+
44
+ def zoom(delta)
45
+ return self unless enabled
46
+
47
+ @zoom_delta += Float(delta) * zoom_speed
48
+ self
49
+ end
50
+
51
+ def pan(delta_x, delta_y)
52
+ return self unless enabled
53
+
54
+ rotation = camera.world_matrix.extract_rotation
55
+ right = rotation * Larb::Vec3.right
56
+ up = rotation * Larb::Vec3.up
57
+ scale = @radius * pan_speed
58
+ @pan_delta = @pan_delta + (right * (-Float(delta_x) * scale)) + (up * (Float(delta_y) * scale))
59
+ self
60
+ end
61
+
62
+ def update(dt = 1.0 / 60.0)
63
+ return self unless enabled
64
+
65
+ response = 1.0 - Math.exp(-damping * [Float(dt), 0.0].max)
66
+ @azimuth += @azimuth_delta * response
67
+ @polar = (@polar + (@polar_delta * response)).clamp(min_polar_angle, max_polar_angle)
68
+ @radius = (@radius * Math.exp(@zoom_delta * response)).clamp(min_distance, max_distance)
69
+ @target = @target + (@pan_delta * response)
70
+ decay = 1.0 - response
71
+ @azimuth_delta *= decay
72
+ @polar_delta *= decay
73
+ @zoom_delta *= decay
74
+ @pan_delta = @pan_delta * decay
75
+ update_camera
76
+ self
77
+ end
78
+
79
+ private
80
+
81
+ def attach(window)
82
+ window.on_pointer_button do |button, pressed, _x, _y|
83
+ @dragging = pressed if left_button?(button)
84
+ end
85
+ window.on_pointer_move do |_x, _y, relative_x, relative_y|
86
+ rotate(relative_x, relative_y) if @dragging
87
+ end
88
+ window.on_scroll { |_x, y| zoom(-y) }
89
+ end
90
+
91
+ def left_button?(button)
92
+ [1, :left, (defined?(GLFW::GLFW_MOUSE_BUTTON_LEFT) && GLFW::GLFW_MOUSE_BUTTON_LEFT)].include?(button)
93
+ end
94
+
95
+ def sync_spherical
96
+ offset = camera.world_position - target
97
+ @radius = [offset.length, min_distance].max
98
+ @azimuth = Math.atan2(offset.x, offset.z)
99
+ @polar = Math.acos((offset.y / @radius).clamp(-1.0, 1.0))
100
+ end
101
+
102
+ def update_camera
103
+ sin_polar = Math.sin(@polar)
104
+ camera.position.set(
105
+ target.x + (@radius * sin_polar * Math.sin(@azimuth)),
106
+ target.y + (@radius * Math.cos(@polar)),
107
+ target.z + (@radius * sin_polar * Math.cos(@azimuth))
108
+ )
109
+ camera.look_at(target)
110
+ end
111
+
112
+ def vector(value)
113
+ value.is_a?(Larb::Vec3) ? value : Larb::Vec3.new(*value.to_a)
114
+ end
115
+ end
116
+
117
+ OrbitControls = Orbit
118
+ end
119
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Attribute
5
+ FORMAT_COMPONENTS = {
6
+ float32: 1, float32x2: 2, float32x3: 3, float32x4: 4,
7
+ uint16: 1, uint16x2: 2, uint16x4: 4,
8
+ sint16x2: 2, sint16x4: 4, snorm16x2: 2, snorm16x4: 4,
9
+ unorm16x2: 2, unorm16x4: 4,
10
+ uint32: 1, uint32x2: 2, uint32x3: 3, uint32x4: 4,
11
+ unorm8x2: 2, unorm8x4: 4, uint8x2: 2, uint8x4: 4,
12
+ sint8x2: 2, sint8x4: 4, snorm8x2: 2, snorm8x4: 4
13
+ }.freeze
14
+ FORMAT_WIDTHS = {
15
+ float32: 4, uint32: 4, uint16: 2,
16
+ uint8: 1, unorm8: 1, sint8: 1, snorm8: 1,
17
+ sint16: 2, snorm16: 2, unorm16: 2
18
+ }.freeze
19
+
20
+ attr_reader :data, :format, :count, :version
21
+
22
+ def initialize(data:, format:, count:, &on_change)
23
+ @format = format.to_sym
24
+ @count = Integer(count)
25
+ @version = 0
26
+ @on_change = on_change
27
+ @initialized = false
28
+ validate!
29
+ self.data = data
30
+ @initialized = true
31
+ end
32
+
33
+ def data=(value)
34
+ raise ArgumentError, "attribute data must be a packed String" unless value.is_a?(String)
35
+
36
+ next_data = value.b.freeze
37
+ expected_size = count * byte_stride
38
+ unless next_data.bytesize == expected_size
39
+ raise ArgumentError, "attribute data must contain #{expected_size} bytes, got #{next_data.bytesize}"
40
+ end
41
+ @data = next_data
42
+ @version += 1
43
+ @on_change&.call if @initialized
44
+ end
45
+
46
+ def components
47
+ FORMAT_COMPONENTS.fetch(format) { raise ArgumentError, "unsupported vertex format #{format.inspect}" }
48
+ end
49
+
50
+ def byte_stride
51
+ prefix = FORMAT_WIDTHS.keys.find { |candidate| format.to_s.start_with?(candidate.to_s) }
52
+ FORMAT_WIDTHS.fetch(prefix) * components
53
+ end
54
+
55
+ private
56
+
57
+ def validate!
58
+ raise ArgumentError, "attribute count must be non-negative" if count.negative?
59
+
60
+ components
61
+ end
62
+ end
63
+
64
+ class IndexAttribute
65
+ attr_reader :data, :format, :count, :version
66
+
67
+ def initialize(data:, format:, &on_change)
68
+ @format = format.to_sym
69
+ raise ArgumentError, "index format must be :uint16 or :uint32" unless %i[uint16 uint32].include?(@format)
70
+
71
+ @version = 0
72
+ @on_change = on_change
73
+ @initialized = false
74
+ self.data = data
75
+ @initialized = true
76
+ end
77
+
78
+ def data=(value)
79
+ raise ArgumentError, "index data must be a packed String" unless value.is_a?(String)
80
+
81
+ next_data = value.b.freeze
82
+ width = format == :uint16 ? 2 : 4
83
+ raise ArgumentError, "index data is not aligned to #{width} bytes" unless (next_data.bytesize % width).zero?
84
+
85
+ @data = next_data
86
+ @count = @data.bytesize / width
87
+ @version += 1
88
+ @on_change&.call if @initialized
89
+ end
90
+ end
91
+ end