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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Renderer
5
+ class Frame
6
+ attr_reader :texture, :width, :height
7
+
8
+ def initialize(texture:, width:, height:, device:, queue:, readable:)
9
+ @texture = texture
10
+ @width = width
11
+ @height = height
12
+ @device = device
13
+ @queue = queue
14
+ @readable = readable
15
+ end
16
+
17
+ def read_pixels
18
+ raise Error, "surface frames cannot be read after presentation; use Renderer.offscreen" unless @readable
19
+
20
+ require "texel"
21
+ bytes_per_row = align(width * 4, 256)
22
+ padded = @queue.read_texture(
23
+ source: { texture: },
24
+ data_layout: { bytes_per_row:, rows_per_image: height },
25
+ size: { width:, height:, depth_or_array_layers: 1 },
26
+ device: @device
27
+ )
28
+ pixels = if bytes_per_row == width * 4
29
+ padded
30
+ else
31
+ height.times.map { |row| padded.byteslice(row * bytes_per_row, width * 4) }.join
32
+ end
33
+ Texel::Image.new(
34
+ width:, height:, channels: 4, dtype: :u8, color_space: :srgb, data: pixels
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def align(value, alignment)
41
+ ((value + alignment - 1) / alignment) * alignment
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Renderer
5
+ class FrameResources
6
+ SkinBinding = Struct.new(:mesh, :skin, :buffer, :bind_group)
7
+ OBJECT_SLOT_SIZE = 256
8
+ OBJECT_DATA_SIZE = 144
9
+ FRAMES_IN_FLIGHT = 3
10
+ LIGHT_SIZE = 80
11
+ GUARANTEED_SAMPLE_COUNTS = [1, 4].freeze
12
+
13
+ attr_reader :device, :queue, :stats, :frame_layout, :object_layout, :empty_layout, :empty_group,
14
+ :shadow_frame_layout, :frame_group, :shadow_frame_group, :object_group,
15
+ :hdr_texture, :hdr_view, :depth_texture, :depth_view, :shadow_texture,
16
+ :shadow_view, :fallback_white, :fallback_black, :fallback_normal,
17
+ :output_texture, :output_view, :width, :height, :sample_count
18
+
19
+ def self.guaranteed_sample_count(requested)
20
+ value = Integer(requested)
21
+ GUARANTEED_SAMPLE_COUNTS.reverse.find { |count| count <= value } ||
22
+ GUARANTEED_SAMPLE_COUNTS.first
23
+ end
24
+
25
+ def initialize(context:, stats:, sample_count:)
26
+ @context = context
27
+ @device = context.device
28
+ @queue = context.queue
29
+ @stats = stats
30
+ @sample_count = self.class.guaranteed_sample_count(sample_count)
31
+ @frame_number = 0
32
+ @object_capacity = 1
33
+ @light_capacity = 1
34
+ @skin_bindings = {}
35
+ create_layouts
36
+ create_buffers
37
+ create_shadow_resources
38
+ create_fallback_textures
39
+ resize(context.width, context.height)
40
+ rescue StandardError
41
+ dispose_after_failed_initialization
42
+ raise
43
+ end
44
+
45
+ def resize(width, height)
46
+ @width = Integer(width)
47
+ @height = Integer(height)
48
+ release_attachments
49
+ create_hdr_attachments
50
+ create_output_attachment unless @context.surface
51
+ self
52
+ end
53
+
54
+ def begin_frame(items, camera:, ambient:, lights:, light_vp:, time:)
55
+ ensure_object_capacity(items.length)
56
+ ensure_light_capacity(lights.length)
57
+ write_frame(camera:, ambient:, lights:, light_vp:, time:)
58
+ write_objects(items)
59
+ write_skins(items)
60
+ @frame_number += 1
61
+ self
62
+ end
63
+
64
+ def object_offset(index)
65
+ frame_slot = (@frame_number - 1) % FRAMES_IN_FLIGHT
66
+ (frame_slot * @object_capacity * OBJECT_SLOT_SIZE) + (index * OBJECT_SLOT_SIZE)
67
+ end
68
+
69
+ def object_group_for(mesh)
70
+ mesh.skin ? @skin_bindings.fetch(mesh.object_id).bind_group : object_group
71
+ end
72
+
73
+ def resize_shadow(map_size)
74
+ next_size = Integer(map_size)
75
+ return self if next_size == @shadow_size
76
+
77
+ release_resource(@shadow_view)
78
+ destroy_texture(@shadow_texture)
79
+ create_shadow_texture(next_size)
80
+ recreate_frame_groups
81
+ self
82
+ end
83
+
84
+ def main_color_attachment
85
+ if sample_count > 1
86
+ {
87
+ view: @hdr_msaa_view,
88
+ resolve_target: hdr_view,
89
+ load_op: :clear,
90
+ store_op: :discard
91
+ }
92
+ else
93
+ { view: hdr_view, load_op: :clear, store_op: :store }
94
+ end
95
+ end
96
+
97
+ def frame_target
98
+ return [output_texture, output_view] unless @context.surface
99
+
100
+ @context.current_target
101
+ end
102
+
103
+ def dispose
104
+ release_attachments
105
+ release_resource(@shadow_sampler, @shadow_view)
106
+ destroy_texture(@shadow_texture)
107
+ [fallback_white, fallback_black, fallback_normal].compact.each do |resource|
108
+ release_gpu_texture(resource)
109
+ end
110
+ release_resource(@frame_group, @shadow_frame_group, @object_group, @empty_group)
111
+ @skin_bindings.each_value do |binding|
112
+ release_resource(binding.bind_group)
113
+ destroy_buffer(binding.buffer)
114
+ end
115
+ @skin_bindings.clear
116
+ [@frame_buffer, @lights_buffer, @object_buffer, @joint_buffer].compact.each do |buffer|
117
+ destroy_buffer(buffer)
118
+ end
119
+ release_resource(frame_layout, object_layout, empty_layout, shadow_frame_layout)
120
+ end
121
+
122
+ private
123
+
124
+ def dispose_after_failed_initialization
125
+ dispose
126
+ rescue StandardError
127
+ nil
128
+ end
129
+
130
+ def create_layouts
131
+ @frame_layout = device.create_bind_group_layout(
132
+ label: "stagecraft frame layout",
133
+ entries: [
134
+ { binding: 0, visibility: %i[vertex fragment], buffer: { type: :uniform } },
135
+ { binding: 1, visibility: :fragment, buffer: { type: :read_only_storage } },
136
+ { binding: 2, visibility: :fragment, texture: { sample_type: :depth } },
137
+ { binding: 3, visibility: :fragment, sampler: { type: :comparison } }
138
+ ]
139
+ )
140
+ @shadow_frame_layout = device.create_bind_group_layout(
141
+ entries: [{ binding: 0, visibility: :vertex, buffer: { type: :uniform } }]
142
+ )
143
+ @object_layout = device.create_bind_group_layout(
144
+ label: "stagecraft object layout",
145
+ entries: [
146
+ {
147
+ binding: 0,
148
+ visibility: %i[vertex fragment],
149
+ buffer: { type: :uniform, has_dynamic_offset: true, min_binding_size: OBJECT_DATA_SIZE }
150
+ },
151
+ { binding: 1, visibility: :vertex, buffer: { type: :read_only_storage } }
152
+ ]
153
+ )
154
+ @empty_layout = device.create_bind_group_layout(entries: [])
155
+ @empty_group = device.create_bind_group(layout: empty_layout, entries: [])
156
+ end
157
+
158
+ def create_buffers
159
+ @frame_buffer = device.create_buffer(
160
+ label: "stagecraft frame uniforms", size: 256, usage: %i[uniform copy_dst]
161
+ )
162
+ stats.increment(:buffers)
163
+ @lights_buffer = device.create_buffer(
164
+ label: "stagecraft lights", size: LIGHT_SIZE, usage: %i[storage copy_dst]
165
+ )
166
+ stats.increment(:buffers)
167
+ @joint_buffer = device.create_buffer(
168
+ label: "stagecraft empty joints", size: 64, usage: %i[storage copy_dst]
169
+ )
170
+ stats.increment(:buffers)
171
+ @object_buffer = create_object_buffer
172
+ stats.increment(:buffers)
173
+ end
174
+
175
+ def create_shadow_resources
176
+ create_shadow_texture(2_048)
177
+ @shadow_sampler = device.create_sampler(
178
+ compare: :less_equal,
179
+ mag_filter: :linear,
180
+ min_filter: :linear
181
+ )
182
+ recreate_frame_groups
183
+ end
184
+
185
+ def create_shadow_texture(size)
186
+ @shadow_size = Integer(size)
187
+ @shadow_texture = device.create_texture(
188
+ label: "stagecraft shadow map",
189
+ size: { width: @shadow_size, height: @shadow_size, depth_or_array_layers: 1 },
190
+ format: :depth32_float,
191
+ usage: %i[render_attachment texture_binding]
192
+ )
193
+ @shadow_view = shadow_texture.create_view
194
+ end
195
+
196
+ def create_fallback_textures
197
+ @fallback_white = solid_texture([255, 255, 255, 255], srgb: true)
198
+ @fallback_black = solid_texture([0, 0, 0, 255], srgb: true)
199
+ @fallback_normal = solid_texture([128, 128, 255, 255], srgb: false)
200
+ end
201
+
202
+ def solid_texture(rgba, srgb:)
203
+ texture = device.create_texture(
204
+ size: { width: 1, height: 1, depth_or_array_layers: 1 },
205
+ format: srgb ? :rgba8_unorm_srgb : :rgba8_unorm,
206
+ usage: %i[texture_binding copy_dst]
207
+ )
208
+ queue.write_texture(
209
+ destination: { texture: },
210
+ data: rgba.pack("C*"),
211
+ data_layout: { bytes_per_row: 4, rows_per_image: 1 },
212
+ size: { width: 1, height: 1, depth_or_array_layers: 1 }
213
+ )
214
+ sampler = device.create_sampler(mag_filter: :linear, min_filter: :linear, mipmap_filter: :linear)
215
+ stats.increment(:textures)
216
+ ResourceCache::GPUTexture.new(texture:, view: texture.create_view, sampler:)
217
+ end
218
+
219
+ def create_hdr_attachments
220
+ @hdr_texture = device.create_texture(
221
+ label: "stagecraft hdr",
222
+ size: extent,
223
+ format: :rgba16_float,
224
+ usage: %i[render_attachment texture_binding copy_src]
225
+ )
226
+ @hdr_view = hdr_texture.create_view
227
+ if sample_count > 1
228
+ @hdr_msaa_texture = device.create_texture(
229
+ label: "stagecraft hdr msaa",
230
+ size: extent,
231
+ format: :rgba16_float,
232
+ usage: :render_attachment,
233
+ sample_count:
234
+ )
235
+ @hdr_msaa_view = @hdr_msaa_texture.create_view
236
+ end
237
+ @depth_texture = device.create_texture(
238
+ label: "stagecraft depth",
239
+ size: extent,
240
+ format: :depth24_plus,
241
+ usage: :render_attachment,
242
+ sample_count:
243
+ )
244
+ @depth_view = depth_texture.create_view
245
+ end
246
+
247
+ def create_output_attachment
248
+ @output_texture = device.create_texture(
249
+ label: "stagecraft output",
250
+ size: extent,
251
+ format: @context.surface_format,
252
+ usage: %i[render_attachment texture_binding copy_src]
253
+ )
254
+ @output_view = output_texture.create_view
255
+ end
256
+
257
+ def ensure_object_capacity(count)
258
+ return if count <= @object_capacity
259
+
260
+ release_resource(@object_group)
261
+ destroy_buffer(@object_buffer)
262
+ @object_capacity *= 2 while @object_capacity < count
263
+ @object_buffer = create_object_buffer
264
+ stats.increment(:buffers)
265
+ recreate_object_group
266
+ end
267
+
268
+ def ensure_light_capacity(count)
269
+ return if count <= @light_capacity
270
+
271
+ destroy_buffer(@lights_buffer)
272
+ @light_capacity *= 2 while @light_capacity < count
273
+ @lights_buffer = device.create_buffer(
274
+ label: "stagecraft lights",
275
+ size: @light_capacity * LIGHT_SIZE,
276
+ usage: %i[storage copy_dst]
277
+ )
278
+ stats.increment(:buffers)
279
+ recreate_frame_groups
280
+ end
281
+
282
+ def create_object_buffer
283
+ device.create_buffer(
284
+ label: "stagecraft object uniforms",
285
+ size: @object_capacity * OBJECT_SLOT_SIZE * FRAMES_IN_FLIGHT,
286
+ usage: %i[uniform copy_dst]
287
+ )
288
+ end
289
+
290
+ def recreate_frame_groups
291
+ release_resource(@frame_group, @shadow_frame_group)
292
+ @frame_group = device.create_bind_group(
293
+ label: "stagecraft frame bind group",
294
+ layout: frame_layout,
295
+ entries: [
296
+ { binding: 0, buffer: @frame_buffer, size: 160 },
297
+ { binding: 1, buffer: @lights_buffer },
298
+ { binding: 2, texture_view: shadow_view },
299
+ { binding: 3, sampler: @shadow_sampler }
300
+ ]
301
+ )
302
+ @shadow_frame_group = device.create_bind_group(
303
+ layout: shadow_frame_layout,
304
+ entries: [{ binding: 0, buffer: @frame_buffer, size: 160 }]
305
+ )
306
+ recreate_object_group
307
+ end
308
+
309
+ def recreate_object_group
310
+ release_resource(@object_group)
311
+ @object_group = device.create_bind_group(
312
+ label: "stagecraft object bind group",
313
+ layout: object_layout,
314
+ entries: [
315
+ { binding: 0, buffer: @object_buffer, size: OBJECT_DATA_SIZE },
316
+ { binding: 1, buffer: @joint_buffer }
317
+ ]
318
+ )
319
+ @skin_bindings.each_value do |binding|
320
+ release_resource(binding.bind_group)
321
+ binding.bind_group = create_object_group(binding.buffer)
322
+ end
323
+ end
324
+
325
+ def create_object_group(joint_buffer)
326
+ device.create_bind_group(
327
+ label: "stagecraft skinned object bind group",
328
+ layout: object_layout,
329
+ entries: [
330
+ { binding: 0, buffer: @object_buffer, size: OBJECT_DATA_SIZE },
331
+ { binding: 1, buffer: joint_buffer }
332
+ ]
333
+ )
334
+ end
335
+
336
+ def write_frame(camera:, ambient:, lights:, light_vp:, time:)
337
+ bytes = String.new(capacity: 160, encoding: Encoding::BINARY)
338
+ bytes << camera.view_projection_matrix.to_a.pack("e*")
339
+ bytes << [*camera.world_position.to_a, time.to_f].pack("e*")
340
+ bytes << ambient.to_a.pack("e*")
341
+ bytes << [lights.length].pack("L<")
342
+ bytes << light_vp.to_a.pack("e*")
343
+ queue.write_buffer(@frame_buffer, 0, bytes)
344
+ return if lights.empty?
345
+
346
+ queue.write_buffer(@lights_buffer, 0, pack_lights(lights))
347
+ end
348
+
349
+ def pack_lights(lights)
350
+ lights.map do |light|
351
+ bytes = "\0".b * LIGHT_SIZE
352
+ kind = { Lights::Directional => 0, Lights::Point => 1, Lights::Spot => 2 }.fetch(light.class, 0)
353
+ bytes[0, 4] = [kind].pack("L<")
354
+ color = light.color.to_a.first(3).map { |component| component * light.intensity }
355
+ bytes[16, 12] = color.pack("e*")
356
+ bytes[32, 12] = light.world_position.to_a.pack("e*")
357
+ bytes[48, 12] = light.direction.to_a.pack("e*")
358
+ range = light.respond_to?(:range) ? light.range : 0.0
359
+ bytes[60, 4] = [range.finite? ? range : 3.4e38].pack("e")
360
+ cone = light.is_a?(Lights::Spot) ? [Math.cos(light.inner_angle), Math.cos(light.outer_angle)] : [1.0, -1.0]
361
+ bytes[64, 8] = cone.pack("e*")
362
+ bytes
363
+ end.join
364
+ end
365
+
366
+ def write_objects(items)
367
+ return if items.empty?
368
+
369
+ frame_slot = @frame_number % FRAMES_IN_FLIGHT
370
+ bytes = "\0".b * (@object_capacity * OBJECT_SLOT_SIZE)
371
+ items.each_with_index do |item, index|
372
+ offset = index * OBJECT_SLOT_SIZE
373
+ model = item.mesh.world_matrix
374
+ bytes[offset, 64] = model.to_a.pack("e*")
375
+ normal = model.inverse.transpose
376
+ bytes[offset + 64, 64] = normal.to_a.pack("e*")
377
+ rescue StandardError
378
+ bytes[offset + 64, 64] = Larb::Mat4.identity.to_a.pack("e*")
379
+ ensure
380
+ bytes[offset + 128, 4] = [item.mesh.receive_shadow ? 1 : 0].pack("L<")
381
+ end
382
+ queue.write_buffer(
383
+ @object_buffer,
384
+ frame_slot * @object_capacity * OBJECT_SLOT_SIZE,
385
+ bytes
386
+ )
387
+ end
388
+
389
+ def write_skins(items)
390
+ prune_skin_bindings(items)
391
+ items.each do |item|
392
+ mesh = item.mesh
393
+ next unless mesh.skin
394
+
395
+ binding = @skin_bindings[mesh.object_id]
396
+ if !binding || !binding.mesh.equal?(mesh) || !binding.skin.equal?(mesh.skin)
397
+ if binding
398
+ release_resource(binding.bind_group)
399
+ destroy_buffer(binding.buffer)
400
+ end
401
+ size = [mesh.skin.joints.length, 1].max * 64
402
+ buffer = device.create_buffer(
403
+ label: "stagecraft joint matrices",
404
+ size:,
405
+ usage: %i[storage copy_dst]
406
+ )
407
+ stats.increment(:buffers)
408
+ binding = SkinBinding.new(mesh, mesh.skin, buffer, create_object_group(buffer))
409
+ @skin_bindings[mesh.object_id] = binding
410
+ end
411
+ matrices = mesh.skin.joint_matrices(mesh)
412
+ bytes = matrices.empty? ? Larb::Mat4.identity.to_a.pack("e*") :
413
+ matrices.flat_map(&:to_a).pack("e*")
414
+ queue.write_buffer(binding.buffer, 0, bytes)
415
+ end
416
+ end
417
+
418
+ def prune_skin_bindings(items)
419
+ active = items.filter_map do |item|
420
+ [item.mesh.object_id, item.mesh] if item.mesh.skin
421
+ end.to_h
422
+ @skin_bindings.delete_if do |object_id, binding|
423
+ next false if active[object_id]&.equal?(binding.mesh)
424
+
425
+ release_resource(binding.bind_group)
426
+ destroy_buffer(binding.buffer)
427
+ true
428
+ end
429
+ end
430
+
431
+ def extent
432
+ { width:, height:, depth_or_array_layers: 1 }
433
+ end
434
+
435
+ def release_attachments
436
+ release_resource(@hdr_view, @hdr_msaa_view, @depth_view, @output_view)
437
+ [@hdr_texture, @hdr_msaa_texture, @depth_texture, @output_texture].compact.each do |texture|
438
+ destroy_texture(texture)
439
+ end
440
+ @hdr_texture = @hdr_view = @hdr_msaa_texture = @hdr_msaa_view = nil
441
+ @depth_texture = @depth_view = @output_texture = @output_view = nil
442
+ end
443
+
444
+ def release_gpu_texture(resource)
445
+ resource.view.release if resource.view.respond_to?(:release)
446
+ resource.sampler.release if resource.sampler.respond_to?(:release)
447
+ destroy_texture(resource.texture)
448
+ stats.decrement(:textures)
449
+ end
450
+
451
+ def destroy_texture(texture)
452
+ texture.destroy if texture.respond_to?(:destroy)
453
+ texture.release if texture.respond_to?(:release)
454
+ end
455
+
456
+ def destroy_buffer(buffer)
457
+ buffer.destroy if buffer.respond_to?(:destroy)
458
+ buffer.release if buffer.respond_to?(:release)
459
+ stats.decrement(:buffers)
460
+ end
461
+
462
+ def release_resource(*resources)
463
+ resources.compact.each { |resource| resource.release if resource.respond_to?(:release) }
464
+ end
465
+ end
466
+ end
467
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Renderer
5
+ class GPUContext
6
+ attr_reader :instance, :adapter, :device, :queue, :surface, :surface_format, :width, :height
7
+
8
+ def initialize(window: nil, width:, height:, device: nil, queue: nil, surface: nil)
9
+ WGPUCompatibility.install!
10
+ @window = window
11
+ @width = Integer(width)
12
+ @height = Integer(height)
13
+ @owns_device = device.nil?
14
+ @disposed = false
15
+ if device
16
+ @device = device
17
+ @queue = queue || device.queue
18
+ @surface = surface
19
+ @adapter = device.respond_to?(:adapter) ? device.adapter : nil
20
+ else
21
+ initialize_wgpu
22
+ end
23
+ @surface_format = choose_surface_format
24
+ configure_surface if @surface
25
+ rescue StandardError
26
+ dispose_after_failed_initialization
27
+ raise
28
+ end
29
+
30
+ def resize(width, height)
31
+ next_width = [Integer(width), 1].max
32
+ next_height = [Integer(height), 1].max
33
+ return false if next_width == @width && next_height == @height
34
+
35
+ @width = next_width
36
+ @height = next_height
37
+ configure_surface if surface
38
+ true
39
+ end
40
+
41
+ def current_target
42
+ raise Error, "renderer has no surface" unless surface
43
+
44
+ texture = surface.current_texture
45
+ [texture, texture.create_view]
46
+ end
47
+
48
+ def present
49
+ surface&.present
50
+ end
51
+
52
+ def dispose
53
+ return self if @disposed
54
+
55
+ @disposed = true
56
+ cleanups = [
57
+ -> { surface&.unconfigure },
58
+ -> { surface&.release }
59
+ ]
60
+ if @owns_device
61
+ cleanups.concat(
62
+ [
63
+ -> { device.destroy if device.respond_to?(:destroy) },
64
+ -> { device.release if device.respond_to?(:release) },
65
+ -> { adapter.release if adapter&.respond_to?(:release) },
66
+ -> { instance.release if instance&.respond_to?(:release) }
67
+ ]
68
+ )
69
+ end
70
+ first_error = nil
71
+ cleanups.each do |cleanup|
72
+ cleanup.call
73
+ rescue StandardError => error
74
+ first_error ||= error
75
+ end
76
+ raise first_error if first_error
77
+
78
+ self
79
+ end
80
+
81
+ private
82
+
83
+ def dispose_after_failed_initialization
84
+ dispose
85
+ rescue StandardError
86
+ nil
87
+ end
88
+
89
+ def initialize_wgpu
90
+ require "wgpu"
91
+ WGPUCompatibility.install!
92
+ @instance = WGPU::Instance.new
93
+ @surface = @window&.create_surface(instance)
94
+ @adapter = instance.request_adapter(compatible_surface: surface)
95
+ @device = adapter.request_device(label: "stagecraft device")
96
+ @queue = device.queue
97
+ end
98
+
99
+ def choose_surface_format
100
+ return :rgba8_unorm_srgb unless surface
101
+
102
+ formats = surface.capabilities(adapter).fetch(:formats)
103
+ formats.find { |format| format.to_s.end_with?("_srgb") } ||
104
+ formats.find { |format| %i[bgra8_unorm rgba8_unorm].include?(format) } ||
105
+ formats.first ||
106
+ :bgra8_unorm
107
+ end
108
+
109
+ def configure_surface
110
+ surface.configure(
111
+ device:,
112
+ format: surface_format,
113
+ width:,
114
+ height:,
115
+ present_mode: :fifo
116
+ )
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stagecraft
4
+ class Renderer
5
+ class PipelineCache
6
+ Key = Data.define(
7
+ :material_class, :feature_bits, :vertex_layout_id, :blend_state, :depth_state,
8
+ :cull_mode, :sample_count, :color_format, :shadow_pass
9
+ )
10
+
11
+ attr_reader :limit
12
+
13
+ def initialize(limit: 256, on_change: nil)
14
+ @limit = Integer(limit)
15
+ raise ArgumentError, "pipeline cache limit must be positive" unless @limit.positive?
16
+
17
+ @entries = {}
18
+ @on_change = on_change
19
+ end
20
+
21
+ def fetch(key)
22
+ if @entries.key?(key)
23
+ value = @entries.delete(key)
24
+ @entries[key] = value
25
+ return value
26
+ end
27
+
28
+ value = yield
29
+ @entries[key] = value
30
+ @on_change&.call(1)
31
+ evict! while @entries.length > limit
32
+ value
33
+ end
34
+
35
+ def size
36
+ @entries.size
37
+ end
38
+
39
+ def clear
40
+ @entries.each_value { |value| release(value) }
41
+ @on_change&.call(-@entries.length)
42
+ @entries.clear
43
+ self
44
+ end
45
+
46
+ private
47
+
48
+ def evict!
49
+ _key, value = @entries.shift
50
+ release(value)
51
+ @on_change&.call(-1)
52
+ end
53
+
54
+ def release(value)
55
+ value.release if value.respond_to?(:release)
56
+ end
57
+ end
58
+ end
59
+ end