wgpu 1.1.0 → 1.2.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +88 -0
  3. data/README.md +44 -5
  4. data/docs/README.md +22 -0
  5. data/docs/api_coverage.md +140 -0
  6. data/docs/async.md +41 -0
  7. data/docs/bind_groups.md +25 -0
  8. data/docs/buffer_data.md +37 -0
  9. data/docs/command_encoding.md +49 -0
  10. data/docs/errors.md +40 -0
  11. data/docs/getting_started_compute.md +62 -0
  12. data/docs/getting_started_rendering.md +62 -0
  13. data/docs/installation.md +94 -0
  14. data/docs/pipeline_descriptors.md +43 -0
  15. data/docs/releasing.md +32 -0
  16. data/docs/resource_lifetime.md +108 -0
  17. data/docs/shaders.md +32 -0
  18. data/docs/texture_readback.md +46 -0
  19. data/docs/troubleshooting.md +53 -0
  20. data/docs/upgrading_wgpu_native.md +37 -0
  21. data/ext/wgpu/extconf.rb +10 -142
  22. data/lib/wgpu/async_task.rb +19 -0
  23. data/lib/wgpu/commands/command_buffer.rb +25 -1
  24. data/lib/wgpu/commands/command_encoder.rb +123 -9
  25. data/lib/wgpu/commands/compute_pass.rb +53 -0
  26. data/lib/wgpu/commands/render_bundle.rb +9 -1
  27. data/lib/wgpu/commands/render_bundle_encoder.rb +65 -4
  28. data/lib/wgpu/commands/render_pass.rb +136 -8
  29. data/lib/wgpu/core/adapter.rb +123 -19
  30. data/lib/wgpu/core/async_waiter.rb +47 -4
  31. data/lib/wgpu/core/canvas_context.rb +32 -2
  32. data/lib/wgpu/core/device.rb +399 -53
  33. data/lib/wgpu/core/instance.rb +28 -4
  34. data/lib/wgpu/core/queue.rb +197 -51
  35. data/lib/wgpu/core/surface.rb +64 -17
  36. data/lib/wgpu/data_types.rb +83 -0
  37. data/lib/wgpu/descriptor_helpers.rb +104 -0
  38. data/lib/wgpu/error.rb +70 -0
  39. data/lib/wgpu/logging.rb +63 -0
  40. data/lib/wgpu/native/abi_verifier.rb +143 -0
  41. data/lib/wgpu/native/callbacks.rb +10 -1
  42. data/lib/wgpu/native/capabilities.rb +32 -2
  43. data/lib/wgpu/native/distribution.rb +176 -0
  44. data/lib/wgpu/native/enum_helper.rb +80 -0
  45. data/lib/wgpu/native/enums.rb +68 -8
  46. data/lib/wgpu/native/fixtures/webgpu-v27.0.4.0-enums.h +848 -0
  47. data/lib/wgpu/native/functions.rb +21 -10
  48. data/lib/wgpu/native/installer.rb +223 -0
  49. data/lib/wgpu/native/loader.rb +61 -21
  50. data/lib/wgpu/native/structs.rb +18 -1
  51. data/lib/wgpu/native_resource.rb +342 -0
  52. data/lib/wgpu/pipeline/bind_group.rb +21 -0
  53. data/lib/wgpu/pipeline/bind_group_layout.rb +108 -41
  54. data/lib/wgpu/pipeline/compute_pipeline.rb +40 -50
  55. data/lib/wgpu/pipeline/pipeline_layout.rb +8 -0
  56. data/lib/wgpu/pipeline/render_pipeline.rb +207 -110
  57. data/lib/wgpu/pipeline/shader_module.rb +95 -28
  58. data/lib/wgpu/resources/buffer.rb +387 -85
  59. data/lib/wgpu/resources/query_set.rb +26 -12
  60. data/lib/wgpu/resources/sampler.rb +43 -20
  61. data/lib/wgpu/resources/texture.rb +89 -48
  62. data/lib/wgpu/resources/texture_view.rb +35 -7
  63. data/lib/wgpu/texture_format.rb +96 -0
  64. data/lib/wgpu/version.rb +1 -1
  65. data/lib/wgpu/window.rb +34 -1
  66. data/lib/wgpu.rb +32 -0
  67. data/sig/wgpu.rbs +460 -0
  68. metadata +33 -16
@@ -6,19 +6,67 @@ module WGPU
6
6
 
7
7
  LIMIT_FIELDS = Native::Limits.members.freeze
8
8
 
9
- def self.request(adapter, label: nil, required_features: [], required_limits: nil)
9
+ # Requests a logical device and waits for the native callback.
10
+ #
11
+ # @param adapter [Adapter] adapter that will create the device
12
+ # @param label [String, nil] optional debugging label
13
+ # @param required_features [Array<Symbol, Integer>] features the device must enable
14
+ # @param required_limits [Hash, nil] minimum required limits
15
+ # @param timeout [Numeric, nil] maximum wait time in seconds
16
+ # @return [Device] requested device
17
+ # @raise [DeviceError] if device creation fails
18
+ # @raise [TimeoutError] if the request exceeds +timeout+
19
+ # @raise [ArgumentError] if +timeout+ is used without an instance-backed adapter
20
+ def self.request(adapter, label: nil, required_features: [], required_limits: nil, timeout: nil)
21
+ if timeout && !adapter.instance
22
+ raise ArgumentError,
23
+ "Device.request timeout requires an instance-backed adapter; " \
24
+ "pass instance: to Adapter.from_handle"
25
+ end
26
+
10
27
  device_ptr = FFI::MemoryPointer.new(:pointer)
11
- status_holder = { done: false, value: nil, message: nil }
28
+ status_holder = {
29
+ done: false,
30
+ value: nil,
31
+ message: nil,
32
+ abandoned: false,
33
+ cleanup_claimed: false,
34
+ mutex: Mutex.new
35
+ }
36
+ device_callback_state = {
37
+ mutex: Mutex.new,
38
+ uncaptured_error: nil,
39
+ device_lost: nil
40
+ }
12
41
 
42
+ callback_token = nil
13
43
  callback = FFI::Function.new(
14
44
  :void, [:uint32, :pointer, Native::StringView.by_value, :pointer, :pointer]
15
45
  ) do |status, device, message, _userdata1, _userdata2|
16
- status_holder[:value] = Native::RequestDeviceStatus[status]
17
- if message[:data] && !message[:data].null? && message[:length] > 0
18
- status_holder[:message] = message[:data].read_string(message[:length])
46
+ cleanup_abandoned = false
47
+ begin
48
+ status_holder[:mutex].synchronize do
49
+ status_holder[:value] = Native::RequestDeviceStatus[status]
50
+ if message[:data] && !message[:data].null? && message[:length] > 0
51
+ status_holder[:message] = message[:data].read_string(message[:length])
52
+ end
53
+ device_ptr.write_pointer(device)
54
+ status_holder[:done] = true
55
+ if status_holder[:abandoned] && !status_holder[:cleanup_claimed]
56
+ status_holder[:cleanup_claimed] = true
57
+ cleanup_abandoned = true
58
+ end
59
+ end
60
+ if cleanup_abandoned
61
+ if status_holder[:value] == :success && device && !device.null?
62
+ release_abandoned_device(device, device_callback_state)
63
+ else
64
+ release_device_callback_keepalive(device_callback_state)
65
+ end
66
+ end
67
+ ensure
68
+ CallbackKeepalive.release(adapter, callback_token)
19
69
  end
20
- device_ptr.write_pointer(device)
21
- status_holder[:done] = true
22
70
  end
23
71
 
24
72
  queue_desc = Native::QueueDescriptor.new
@@ -28,14 +76,16 @@ module WGPU
28
76
 
29
77
  device_lost_info = Native::DeviceLostCallbackInfo.new
30
78
  device_lost_info[:next_in_chain] = nil
31
- device_lost_info[:mode] = 0
32
- device_lost_info[:callback] = nil
79
+ device_lost_info[:mode] = AsyncWaiter.callback_mode(instance: adapter.instance)
80
+ device_lost_callback = build_device_lost_callback(device_callback_state)
81
+ device_lost_info[:callback] = device_lost_callback
33
82
  device_lost_info[:userdata1] = nil
34
83
  device_lost_info[:userdata2] = nil
35
84
 
36
85
  error_info = Native::UncapturedErrorCallbackInfo.new
37
86
  error_info[:next_in_chain] = nil
38
- error_info[:callback] = nil
87
+ uncaptured_error_callback = build_uncaptured_error_callback(device_callback_state)
88
+ error_info[:callback] = uncaptured_error_callback
39
89
  error_info[:userdata1] = nil
40
90
  error_info[:userdata2] = nil
41
91
 
@@ -74,62 +124,163 @@ module WGPU
74
124
  callback_info[:userdata1] = nil
75
125
  callback_info[:userdata2] = nil
76
126
 
77
- future = Native.wgpuAdapterRequestDevice(adapter.handle, desc, callback_info)
78
- AsyncWaiter.wait(status_holder: status_holder, instance: adapter.instance, future: future)
127
+ device_lost_token = CallbackKeepalive.retain(adapter, device_lost_callback)
128
+ uncaptured_error_token = CallbackKeepalive.retain(adapter, uncaptured_error_callback)
129
+ configure_device_callback_keepalive(
130
+ device_callback_state,
131
+ owner: adapter,
132
+ tokens: [device_lost_token, uncaptured_error_token]
133
+ )
134
+ callback_token = CallbackKeepalive.retain(adapter, callback)
135
+ future =
136
+ begin
137
+ Native.wgpuAdapterRequestDevice(adapter.handle, desc, callback_info)
138
+ rescue StandardError
139
+ CallbackKeepalive.release(adapter, callback_token)
140
+ release_device_callback_keepalive(device_callback_state)
141
+ raise
142
+ end
143
+
144
+ begin
145
+ AsyncWaiter.wait(
146
+ status_holder: status_holder,
147
+ instance: adapter.instance,
148
+ future: future,
149
+ timeout: timeout
150
+ )
151
+ rescue TimeoutError
152
+ abandoned_device = nil
153
+ cleanup_abandoned = status_holder[:mutex].synchronize do
154
+ status_holder[:abandoned] = true
155
+ next false unless status_holder[:done] && !status_holder[:cleanup_claimed]
156
+
157
+ status_holder[:cleanup_claimed] = true
158
+ abandoned_device = device_ptr.read_pointer
159
+ true
160
+ end
161
+ if cleanup_abandoned
162
+ if status_holder[:value] == :success && abandoned_device && !abandoned_device.null?
163
+ release_abandoned_device(abandoned_device, device_callback_state)
164
+ else
165
+ release_device_callback_keepalive(device_callback_state)
166
+ end
167
+ end
168
+ raise
169
+ end
79
170
 
80
171
  handle = device_ptr.read_pointer
81
172
  if handle.null? || status_holder[:value] != :success
173
+ release_device_callback_keepalive(device_callback_state)
82
174
  msg = status_holder[:message] || "Unknown error"
83
175
  raise DeviceError, "Failed to request device: #{msg}"
84
176
  end
85
177
 
86
- new(handle, adapter: adapter)
178
+ begin
179
+ callback_lifetime = DeviceCallbackLifetime.new do
180
+ release_device_callback_keepalive(device_callback_state)
181
+ end
182
+ device_callback_state[:mutex].synchronize do
183
+ device_callback_state[:callback_lifetime] = callback_lifetime
184
+ end
185
+ device = new(
186
+ handle,
187
+ adapter: adapter,
188
+ label: label,
189
+ callback_state: device_callback_state,
190
+ callback_lifetime: callback_lifetime
191
+ )
192
+ device.send(:adopt_device_callback_keepalive)
193
+ device
194
+ rescue StandardError
195
+ device ? device.release : release_abandoned_device(handle, device_callback_state)
196
+ raise
197
+ end
87
198
  end
88
199
 
89
- def initialize(handle, adapter: nil)
200
+ # Wraps a native device and obtains its default queue.
201
+ # @param handle [FFI::Pointer] native device handle
202
+ # @param adapter [Adapter, nil] adapter that created the device
203
+ # @param label [String, nil] optional debug label
204
+ # @param callback_lifetime [DeviceCallbackLifetime, nil] shared native callback lifetime
205
+ def initialize(handle, adapter: nil, label: nil, callback_state: nil, callback_lifetime: nil)
90
206
  @handle = handle
91
207
  @adapter = adapter
208
+ @label = label
209
+ @device_callback_lifetime = callback_lifetime
210
+ @device_callback_state = callback_state || {
211
+ mutex: Mutex.new,
212
+ uncaptured_error: nil,
213
+ device_lost: nil
214
+ }
215
+ @device_callback_tokens = []
92
216
  @queue = Queue.new(Native.wgpuDeviceGetQueue(@handle), device: self)
93
217
  end
94
218
 
219
+ # Returns information about the adapter that created this device.
220
+ # @return [Hash, nil]
95
221
  def adapter_info
96
222
  @adapter&.info
97
223
  end
98
224
 
225
+ # Creates a GPU buffer.
226
+ # @return [Buffer]
99
227
  def create_buffer(label: nil, size:, usage:, mapped_at_creation: false)
100
228
  Buffer.new(self, label: label, size: size, usage: usage, mapped_at_creation: mapped_at_creation)
101
229
  end
102
230
 
103
- def create_shader_module(label: nil, code:, compilation_hints: [])
104
- ShaderModule.new(self, label: label, code: code, compilation_hints: compilation_hints)
231
+ # Creates a shader module from WGSL or SPIR-V source.
232
+ # @return [ShaderModule]
233
+ def create_shader_module(label: nil, code: nil, spirv: nil, compilation_hints: [], validate: false)
234
+ ShaderModule.new(
235
+ self,
236
+ label: label,
237
+ code: code,
238
+ spirv: spirv,
239
+ compilation_hints: compilation_hints,
240
+ validate: validate
241
+ )
105
242
  end
106
243
 
244
+ # Creates a command encoder.
245
+ # @return [CommandEncoder]
107
246
  def create_command_encoder(label: nil)
108
247
  CommandEncoder.new(self, label: label)
109
248
  end
110
249
 
250
+ # Creates a bind group layout.
251
+ # @return [BindGroupLayout]
111
252
  def create_bind_group_layout(label: nil, entries:)
112
253
  BindGroupLayout.new(self, label: label, entries: entries)
113
254
  end
114
255
 
256
+ # Creates a bind group.
257
+ # @return [BindGroup]
115
258
  def create_bind_group(label: nil, layout:, entries:)
116
259
  BindGroup.new(self, label: label, layout: layout, entries: entries)
117
260
  end
118
261
 
262
+ # Creates a pipeline layout.
263
+ # @return [PipelineLayout]
119
264
  def create_pipeline_layout(label: nil, bind_group_layouts:)
120
265
  PipelineLayout.new(self, label: label, bind_group_layouts: bind_group_layouts)
121
266
  end
122
267
 
268
+ # Creates a compute pipeline.
269
+ # @return [ComputePipeline]
123
270
  def create_compute_pipeline(label: nil, layout:, compute:)
124
271
  ComputePipeline.new(self, label: label, layout: layout, compute: compute)
125
272
  end
126
273
 
274
+ # Creates a compute pipeline on a background task.
275
+ # @return [AsyncTask] task yielding a {ComputePipeline}
127
276
  def create_compute_pipeline_async(label: nil, layout:, compute:)
128
277
  AsyncTask.new do
129
278
  create_compute_pipeline(label: label, layout: layout, compute: compute)
130
279
  end
131
280
  end
132
281
 
282
+ # Creates a render pipeline.
283
+ # @return [RenderPipeline]
133
284
  def create_render_pipeline(label: nil, layout:, vertex:, primitive: {}, depth_stencil: nil, multisample: {}, fragment: nil)
134
285
  RenderPipeline.new(self,
135
286
  label: label,
@@ -142,6 +293,8 @@ module WGPU
142
293
  )
143
294
  end
144
295
 
296
+ # Creates a render pipeline on a background task.
297
+ # @return [AsyncTask] task yielding a {RenderPipeline}
145
298
  def create_render_pipeline_async(label: nil, layout:, vertex:, primitive: {}, depth_stencil: nil, multisample: {}, fragment: nil)
146
299
  AsyncTask.new do
147
300
  create_render_pipeline(
@@ -156,6 +309,8 @@ module WGPU
156
309
  end
157
310
  end
158
311
 
312
+ # Creates a texture.
313
+ # @return [Texture]
159
314
  def create_texture(label: nil, size:, format:, usage:, dimension: :d2, mip_level_count: 1, sample_count: 1, view_formats: [])
160
315
  Texture.new(self,
161
316
  label: label,
@@ -169,6 +324,8 @@ module WGPU
169
324
  )
170
325
  end
171
326
 
327
+ # Creates a texture sampler.
328
+ # @return [Sampler]
172
329
  def create_sampler(label: nil, address_mode_u: :clamp_to_edge, address_mode_v: :clamp_to_edge, address_mode_w: :clamp_to_edge, mag_filter: :nearest, min_filter: :nearest, mipmap_filter: :nearest, lod_min_clamp: 0.0, lod_max_clamp: 32.0, compare: nil, max_anisotropy: 1)
173
330
  Sampler.new(self,
174
331
  label: label,
@@ -185,8 +342,14 @@ module WGPU
185
342
  )
186
343
  end
187
344
 
188
- def create_buffer_with_data(label: nil, data:, usage:)
189
- data_ptr, byte_size = data_to_pointer(data)
345
+ # Creates a mapped buffer initialized from typed Ruby data.
346
+ # @param data [Array, String, FFI::Pointer] source data
347
+ # @param usage [Symbol, Array<Symbol>, Integer] buffer usage flags
348
+ # @param type [Symbol] source element type
349
+ # @return [Buffer]
350
+ def create_buffer_with_data(label: nil, data:, usage:, type: :f32)
351
+ data_ptr, byte_size = DataTypes.to_pointer(data, type:)
352
+ DataTypes.validate_alignment!(byte_size, 4, name: "buffer data size")
190
353
  buffer = create_buffer(
191
354
  label: label,
192
355
  size: byte_size,
@@ -198,10 +361,14 @@ module WGPU
198
361
  buffer
199
362
  end
200
363
 
364
+ # Creates a GPU query set.
365
+ # @return [QuerySet]
201
366
  def create_query_set(label: nil, type:, count:)
202
367
  QuerySet.new(self, label: label, type: type, count: count)
203
368
  end
204
369
 
370
+ # Creates an encoder for reusable render commands.
371
+ # @return [RenderBundleEncoder]
205
372
  def create_render_bundle_encoder(color_formats:, depth_stencil_format: nil, sample_count: 1,
206
373
  depth_read_only: false, stencil_read_only: false, label: nil)
207
374
  RenderBundleEncoder.new(self,
@@ -214,6 +381,8 @@ module WGPU
214
381
  )
215
382
  end
216
383
 
384
+ # Lists optional features enabled on the device.
385
+ # @return [Array<Symbol>]
217
386
  def features
218
387
  supported = Native::SupportedFeatures.new
219
388
  Native.wgpuDeviceGetFeatures(@handle, supported)
@@ -227,10 +396,15 @@ module WGPU
227
396
  result
228
397
  end
229
398
 
399
+ # Reports whether a feature is enabled on the device.
400
+ # @param feature [Symbol] feature name
401
+ # @return [Boolean]
230
402
  def has_feature?(feature)
231
403
  features.include?(feature)
232
404
  end
233
405
 
406
+ # Returns resource limits supported by the device.
407
+ # @return [Hash{Symbol => Integer}]
234
408
  def limits
235
409
  supported = Native::SupportedLimits.new
236
410
  supported[:next_in_chain] = nil
@@ -238,6 +412,9 @@ module WGPU
238
412
  limits_to_hash(supported[:limits])
239
413
  end
240
414
 
415
+ # Processes device work, optionally waiting for queue progress.
416
+ # @param wait [Boolean] whether to wait for submitted work
417
+ # @return [Integer] native poll status
241
418
  def poll(wait: false)
242
419
  if Native.device_poll_available?
243
420
  Native.wgpuDevicePoll(@handle, wait ? 1 : 0, nil)
@@ -247,21 +424,35 @@ module WGPU
247
424
  end
248
425
  end
249
426
 
427
+ # Pushes a scope that captures matching GPU errors.
428
+ # @param filter [Symbol, Integer] error filter
429
+ # @return [void]
250
430
  def push_error_scope(filter = :validation)
251
- Native.wgpuDevicePushErrorScope(@handle, filter)
431
+ filter_value = Native::EnumHelper.coerce(Native::ErrorFilter, filter, name: "error filter")
432
+ Native.wgpuDevicePushErrorScope(@handle, filter_value)
252
433
  end
253
434
 
254
- def pop_error_scope
435
+ # Pops the latest error scope and waits for its result.
436
+ # @param timeout [Numeric, nil] maximum wait time in seconds
437
+ # @return [Hash] native status, error type, and message
438
+ def pop_error_scope(timeout: nil)
255
439
  error_holder = { done: false, status: nil, type: nil, message: nil }
256
440
 
441
+ callback_lifetime_release = device_callback_lifetime_lease
442
+ callback_token = nil
257
443
  callback = FFI::Function.new(
258
444
  :void, [:uint32, :uint32, Native::StringView.by_value, :pointer, :pointer]
259
445
  ) do |status, error_type, message, _userdata1, _userdata2|
260
- error_holder[:done] = true
261
- error_holder[:status] = Native::PopErrorScopeStatus[status]
262
- error_holder[:type] = Native::ErrorType[error_type]
263
- if message[:data] && !message[:data].null? && message[:length] > 0
264
- error_holder[:message] = message[:data].read_string(message[:length])
446
+ begin
447
+ error_holder[:status] = Native::PopErrorScopeStatus[status]
448
+ error_holder[:type] = Native::ErrorType[error_type]
449
+ if message[:data] && !message[:data].null? && message[:length] > 0
450
+ error_holder[:message] = message[:data].read_string(message[:length])
451
+ end
452
+ error_holder[:done] = true
453
+ ensure
454
+ CallbackKeepalive.release(self, callback_token)
455
+ callback_lifetime_release.call
265
456
  end
266
457
  end
267
458
 
@@ -272,40 +463,98 @@ module WGPU
272
463
  callback_info[:userdata1] = nil
273
464
  callback_info[:userdata2] = nil
274
465
 
275
- future = Native.wgpuDevicePopErrorScope(@handle, callback_info)
276
- AsyncWaiter.wait(status_holder: error_holder, instance: @adapter&.instance, device: self, future: future)
466
+ callback_token = CallbackKeepalive.retain(self, callback)
467
+ future =
468
+ begin
469
+ Native.wgpuDevicePopErrorScope(@handle, callback_info)
470
+ rescue StandardError
471
+ CallbackKeepalive.release(self, callback_token)
472
+ callback_lifetime_release.call
473
+ raise
474
+ end
475
+ AsyncWaiter.wait(
476
+ status_holder: error_holder,
477
+ instance: @adapter&.instance,
478
+ device: self,
479
+ future: future,
480
+ timeout: timeout
481
+ )
277
482
 
278
483
  error_holder
279
484
  end
280
485
 
281
- def pop_error_scope_async
282
- AsyncTask.new { pop_error_scope }
486
+ # Pops the latest error scope on a background thread.
487
+ #
488
+ # @param timeout [Numeric, nil] maximum wait time in seconds
489
+ # @return [AsyncTask] task whose value is the error hash
490
+ def pop_error_scope_async(timeout: nil)
491
+ AsyncTask.new { pop_error_scope(timeout: timeout) }
283
492
  end
284
493
 
494
+ # Pops the latest error scope as a typed error.
495
+ # @param timeout [Numeric, nil] maximum wait time in seconds
496
+ # @return [GPUError, nil]
497
+ def pop_error_scope_typed(timeout: nil)
498
+ GPUError.from_hash(pop_error_scope(timeout: timeout))
499
+ end
500
+
501
+ # Runs a block inside an error scope and raises captured GPU errors.
502
+ # @param filter [Symbol, Integer] error filter
503
+ # @yieldreturn [Object] block result
504
+ # @return [Object] block result
285
505
  def with_error_scope(filter = :validation)
286
506
  push_error_scope(filter)
287
507
  result = yield
288
- error = pop_error_scope
289
- if error[:type] && error[:type] != :no_error
290
- raise Error, "GPU error (#{error[:type]}): #{error[:message]}"
291
- end
508
+ error = GPUError.from_hash(pop_error_scope)
509
+ error&.raise!
292
510
  result
293
511
  end
294
512
 
513
+ # Registers the handler for uncaptured GPU errors.
514
+ # @yieldparam error [GPUError] reported error
515
+ # @return [Device] this device
516
+ # @raise [ArgumentError] if no block is provided
517
+ def on_uncaptured_error(&handler)
518
+ raise ArgumentError, "on_uncaptured_error requires a block" unless handler
519
+
520
+ set_device_callback(:uncaptured_error, handler)
521
+ self
522
+ end
523
+
524
+ # Registers the handler invoked when the device is lost.
525
+ # @yieldparam reason [Symbol] native device-lost reason
526
+ # @yieldparam message [String] native diagnostic message
527
+ # @return [Device] this device
528
+ # @raise [ArgumentError] if no block is provided
529
+ def on_device_lost(&handler)
530
+ raise ArgumentError, "on_device_lost requires a block" unless handler
531
+
532
+ set_device_callback(:device_lost, handler)
533
+ self
534
+ end
535
+
536
+ # Destroys device-owned native resources.
537
+ # @return [void]
295
538
  def destroy
296
539
  return if @handle.null?
297
540
  Native.wgpuDeviceDestroy(@handle)
298
541
  end
299
542
 
543
+ # Releases the default queue and native device handle.
544
+ #
545
+ # Device callbacks remain alive until all derived wrappers and pending
546
+ # callback operations have also completed their native releases.
547
+ #
548
+ # Calling this method more than once has no effect.
549
+ # @return [void]
300
550
  def release
301
551
  @queue&.release
302
552
  return if @handle.null?
303
553
  Native.wgpuDeviceRelease(@handle)
304
554
  @handle = FFI::Pointer::NULL
555
+ @device_callback_tokens = nil
305
556
  end
306
557
 
307
- private
308
-
309
558
  def self.normalize_required_features(required_features)
310
559
  Array(required_features).map do |feature|
311
560
  normalize_feature_name(feature)
@@ -316,10 +565,7 @@ module WGPU
316
565
  return feature if feature.is_a?(Integer)
317
566
 
318
567
  key = feature.to_s.strip.tr("-", "_").to_sym
319
- value = Native::FeatureName[key]
320
- raise ArgumentError, "Unknown feature name: #{feature}" if value.nil?
321
-
322
- value
568
+ Native::EnumHelper.coerce(Native::FeatureName, key, name: "feature name")
323
569
  end
324
570
 
325
571
  def self.build_required_limits(adapter, required_limits)
@@ -352,21 +598,121 @@ module WGPU
352
598
  raise ArgumentError, "Unknown limit key: #{name}"
353
599
  end
354
600
 
355
- def data_to_pointer(data)
356
- case data
357
- when String
358
- ptr = FFI::MemoryPointer.new(:char, data.bytesize)
359
- ptr.put_bytes(0, data)
360
- [ptr, data.bytesize]
361
- when Array
362
- ptr = FFI::MemoryPointer.new(:float, data.size)
363
- ptr.write_array_of_float(data)
364
- [ptr, data.size * 4]
365
- when FFI::Pointer
366
- [data, data.size]
367
- else
368
- raise ArgumentError, "Unsupported data type: #{data.class}"
601
+ def self.build_uncaptured_error_callback(state)
602
+ FFI::Function.new(
603
+ :void, [:pointer, :uint32, Native::StringView.by_value, :pointer, :pointer]
604
+ ) do |_device, type, message, _userdata1, _userdata2|
605
+ callback_lifetime = retain_device_callback_lifetime(state)
606
+ begin
607
+ error = GPUError.new(
608
+ type: Native::ErrorType[type] || :unknown,
609
+ message: string_from_callback(message)
610
+ )
611
+ dispatch_device_callback(state, :uncaptured_error, error) do
612
+ warn "Uncaptured GPU error (#{error.type}): #{error.message}"
613
+ end
614
+ ensure
615
+ callback_lifetime&.release
616
+ end
617
+ end
618
+ end
619
+
620
+ def self.build_device_lost_callback(state)
621
+ FFI::Function.new(
622
+ :void, [:pointer, :uint32, Native::StringView.by_value, :pointer, :pointer]
623
+ ) do |_device, reason, message, _userdata1, _userdata2|
624
+ callback_lifetime = retain_device_callback_lifetime(state)
625
+ begin
626
+ reason_name = Native::DeviceLostReason[reason] || :unknown
627
+ message_text = string_from_callback(message)
628
+ dispatch_device_callback(state, :device_lost, reason_name, message_text) do
629
+ warn "GPU device lost (#{reason_name}): #{message_text}" unless reason_name == :destroyed
630
+ end
631
+ ensure
632
+ callback_lifetime&.release
633
+ end
634
+ end
635
+ end
636
+
637
+ def self.retain_device_callback_lifetime(state)
638
+ state[:mutex].synchronize do
639
+ lifetime = state[:callback_lifetime]
640
+ lifetime&.retain
641
+ lifetime
642
+ end
643
+ end
644
+
645
+ def self.configure_device_callback_keepalive(state, owner:, tokens:)
646
+ state[:mutex].synchronize do
647
+ state[:callback_owner] = owner
648
+ state[:callback_tokens] = tokens
649
+ state[:callbacks_completed] = false
650
+ end
651
+ end
652
+
653
+ def self.transfer_device_callback_keepalive(state, new_owner)
654
+ state[:mutex].synchronize do
655
+ return [] if state[:callbacks_completed]
656
+
657
+ previous_owner = state[:callback_owner]
658
+ tokens = Array(state[:callback_tokens])
659
+ transferred = tokens.select do |token|
660
+ CallbackKeepalive.transfer(previous_owner, new_owner, token)
661
+ end
662
+ state[:callback_owner] = new_owner
663
+ state[:callback_tokens] = transferred
664
+ transferred
665
+ end
666
+ end
667
+
668
+ def self.release_device_callback_keepalive(state)
669
+ owner, tokens = state[:mutex].synchronize do
670
+ return if state[:callbacks_completed]
671
+
672
+ state[:callbacks_completed] = true
673
+ retained_owner = state.delete(:callback_owner)
674
+ retained_tokens = Array(state.delete(:callback_tokens))
675
+ state.delete(:callback_lifetime)
676
+ [retained_owner, retained_tokens]
369
677
  end
678
+ tokens.each { |token| CallbackKeepalive.release(owner, token) } if owner
679
+ end
680
+
681
+ def self.release_abandoned_device(device, callback_state)
682
+ Native.wgpuDeviceRelease(device)
683
+ ensure
684
+ release_device_callback_keepalive(callback_state)
685
+ end
686
+
687
+ def self.dispatch_device_callback(state, key, *args)
688
+ handler = state[:mutex].synchronize { state[key] }
689
+ handler ? handler.call(*args) : yield
690
+ rescue StandardError => e
691
+ warn "WGPU #{key} handler failed: #{e.class}: #{e.message}"
692
+ end
693
+
694
+ def self.string_from_callback(message)
695
+ return "" if message[:data].nil? || message[:data].null? || message[:length].zero?
696
+
697
+ message[:data].read_string(message[:length])
698
+ end
699
+
700
+ private_class_method :normalize_required_features, :normalize_feature_name,
701
+ :build_required_limits, :canonical_limit_key, :build_uncaptured_error_callback,
702
+ :build_device_lost_callback, :retain_device_callback_lifetime,
703
+ :configure_device_callback_keepalive,
704
+ :transfer_device_callback_keepalive, :release_device_callback_keepalive,
705
+ :release_abandoned_device, :dispatch_device_callback, :string_from_callback
706
+
707
+ private
708
+
709
+ def adopt_device_callback_keepalive
710
+ @device_callback_tokens =
711
+ self.class.send(:transfer_device_callback_keepalive, @device_callback_state, self)
712
+ end
713
+
714
+ def set_device_callback(name, handler)
715
+ @device_callback_state[:mutex].synchronize { @device_callback_state[name] = handler }
370
716
  end
371
717
 
372
718
  def limits_to_hash(limits)