wgpu 1.1.0 → 1.2.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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +26 -3
  4. data/docs/README.md +22 -0
  5. data/docs/api_coverage.md +132 -0
  6. data/docs/async.md +31 -0
  7. data/docs/bind_groups.md +25 -0
  8. data/docs/buffer_data.md +37 -0
  9. data/docs/command_encoding.md +24 -0
  10. data/docs/errors.md +40 -0
  11. data/docs/getting_started_compute.md +61 -0
  12. data/docs/getting_started_rendering.md +62 -0
  13. data/docs/installation.md +94 -0
  14. data/docs/pipeline_descriptors.md +38 -0
  15. data/docs/releasing.md +22 -0
  16. data/docs/resource_lifetime.md +94 -0
  17. data/docs/shaders.md +32 -0
  18. data/docs/texture_readback.md +19 -0
  19. data/docs/troubleshooting.md +48 -0
  20. data/docs/upgrading_wgpu_native.md +26 -0
  21. data/ext/wgpu/extconf.rb +10 -142
  22. data/lib/wgpu/commands/command_buffer.rb +11 -0
  23. data/lib/wgpu/commands/command_encoder.rb +65 -8
  24. data/lib/wgpu/commands/compute_pass.rb +10 -0
  25. data/lib/wgpu/commands/render_bundle_encoder.rb +10 -3
  26. data/lib/wgpu/commands/render_pass.rb +34 -8
  27. data/lib/wgpu/core/adapter.rb +36 -11
  28. data/lib/wgpu/core/async_waiter.rb +32 -4
  29. data/lib/wgpu/core/canvas_context.rb +0 -2
  30. data/lib/wgpu/core/device.rb +138 -42
  31. data/lib/wgpu/core/instance.rb +8 -4
  32. data/lib/wgpu/core/queue.rb +80 -49
  33. data/lib/wgpu/core/surface.rb +16 -17
  34. data/lib/wgpu/data_types.rb +67 -0
  35. data/lib/wgpu/descriptor_helpers.rb +39 -0
  36. data/lib/wgpu/error.rb +55 -0
  37. data/lib/wgpu/logging.rb +63 -0
  38. data/lib/wgpu/native/abi_verifier.rb +109 -0
  39. data/lib/wgpu/native/callbacks.rb +4 -1
  40. data/lib/wgpu/native/capabilities.rb +18 -2
  41. data/lib/wgpu/native/distribution.rb +113 -0
  42. data/lib/wgpu/native/enum_helper.rb +63 -0
  43. data/lib/wgpu/native/enums.rb +60 -8
  44. data/lib/wgpu/native/functions.rb +10 -10
  45. data/lib/wgpu/native/installer.rb +192 -0
  46. data/lib/wgpu/native/loader.rb +39 -21
  47. data/lib/wgpu/native_resource.rb +171 -0
  48. data/lib/wgpu/pipeline/bind_group.rb +12 -0
  49. data/lib/wgpu/pipeline/bind_group_layout.rb +91 -37
  50. data/lib/wgpu/pipeline/compute_pipeline.rb +28 -26
  51. data/lib/wgpu/pipeline/render_pipeline.rb +180 -68
  52. data/lib/wgpu/pipeline/shader_module.rb +50 -8
  53. data/lib/wgpu/resources/buffer.rb +148 -73
  54. data/lib/wgpu/resources/query_set.rb +14 -12
  55. data/lib/wgpu/resources/sampler.rb +36 -20
  56. data/lib/wgpu/resources/texture.rb +44 -42
  57. data/lib/wgpu/resources/texture_view.rb +11 -3
  58. data/lib/wgpu/texture_format.rb +82 -0
  59. data/lib/wgpu/version.rb +1 -1
  60. data/lib/wgpu/window.rb +8 -1
  61. data/lib/wgpu.rb +32 -0
  62. data/sig/wgpu.rbs +381 -0
  63. metadata +31 -16
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ require_relative "distribution"
9
+
10
+ module WGPU
11
+ module Native
12
+ class InstallError < StandardError; end
13
+
14
+ class Installer
15
+ attr_reader :platform
16
+
17
+ def initialize(platform: RUBY_PLATFORM, env: ENV, home: Dir.home,
18
+ host_os: RbConfig::CONFIG["host_os"], output: $stdout)
19
+ @platform = platform
20
+ @env = env
21
+ @home = home
22
+ @host_os = host_os
23
+ @output = output
24
+ end
25
+
26
+ def install
27
+ return custom_library_path if custom_library_path
28
+
29
+ artifact = Distribution.artifact_for(platform)
30
+ cached_path = existing_library_path(artifact)
31
+ if cached_path
32
+ @output.puts "wgpu-native already cached at #{cached_path}"
33
+ return cached_path
34
+ end
35
+
36
+ install_artifact(artifact)
37
+ rescue LoadError, InstallError => error
38
+ raise InstallError, "#{error.message}\n\n#{recovery_instructions}"
39
+ end
40
+
41
+ def clean_path
42
+ Distribution.primary_cache_dir(env: @env, home: @home, host_os: @host_os)
43
+ end
44
+
45
+ private
46
+
47
+ def custom_library_path
48
+ path = @env["WGPU_LIB_PATH"]
49
+ return if path.nil? || path.empty?
50
+ raise InstallError, "WGPU_LIB_PATH points to a non-existent file: #{path}" unless File.file?(path)
51
+
52
+ File.expand_path(path)
53
+ end
54
+
55
+ def existing_library_path(artifact)
56
+ Distribution.cache_directories(env: @env, home: @home, host_os: @host_os).each do |directory|
57
+ path = File.join(directory, "lib", artifact.fetch(:library))
58
+ return path if File.file?(path)
59
+ end
60
+ nil
61
+ end
62
+
63
+ def install_artifact(artifact)
64
+ cache_dir = clean_path
65
+ archive_path = File.join(cache_dir, artifact.fetch(:archive))
66
+ library_path = File.join(cache_dir, "lib", artifact.fetch(:library))
67
+ FileUtils.mkdir_p(cache_dir)
68
+
69
+ @output.puts "Downloading wgpu-native #{Distribution::VERSION} from GitHub..."
70
+ @output.puts " URL: #{Distribution.release_url(artifact)}"
71
+ download_file(Distribution.release_url(artifact), archive_path)
72
+ verify_checksum!(archive_path, artifact.fetch(:sha256))
73
+
74
+ @output.puts "Extracting to #{cache_dir}..."
75
+ extract_zip(archive_path, cache_dir)
76
+ raise InstallError, "Archive did not contain lib/#{artifact.fetch(:library)}" unless File.file?(library_path)
77
+
78
+ @output.puts "wgpu-native installed successfully!"
79
+ library_path
80
+ ensure
81
+ FileUtils.rm_f(archive_path) if archive_path
82
+ end
83
+
84
+ def verify_checksum!(path, expected)
85
+ actual = Digest::SHA256.file(path).hexdigest
86
+ return if actual == expected
87
+
88
+ FileUtils.rm_f(path)
89
+ raise InstallError, "SHA-256 mismatch for #{File.basename(path)} (expected #{expected}, got #{actual})"
90
+ end
91
+
92
+ def download_file(url, destination)
93
+ return if curl_available? && download_with_curl(url, destination)
94
+ return if download_with_ruby(url, destination)
95
+
96
+ raise InstallError, "Download failed: #{url}"
97
+ end
98
+
99
+ def curl_available?
100
+ system("curl", "--version", out: File::NULL, err: File::NULL)
101
+ end
102
+
103
+ def download_with_curl(url, destination)
104
+ system("curl", "-fsSL", "-o", destination, url)
105
+ end
106
+
107
+ def download_with_ruby(url, destination, redirects = 5)
108
+ raise InstallError, "Too many redirects while downloading #{url}" if redirects.zero?
109
+
110
+ uri = URI.parse(url)
111
+ http = Net::HTTP.new(uri.host, uri.port)
112
+ http.use_ssl = uri.scheme == "https"
113
+ http.open_timeout = 10
114
+ http.read_timeout = 120
115
+
116
+ http.request(Net::HTTP::Get.new(uri.request_uri)) do |response|
117
+ case response
118
+ when Net::HTTPRedirection
119
+ location = response["location"]
120
+ raise InstallError, "Redirect response did not include a location" unless location
121
+
122
+ location = URI.join(url, location).to_s
123
+ return download_with_ruby(location, destination, redirects - 1)
124
+ when Net::HTTPSuccess
125
+ File.open(destination, "wb") do |file|
126
+ response.read_body { |chunk| file.write(chunk) }
127
+ end
128
+ return true
129
+ end
130
+ end
131
+
132
+ false
133
+ rescue SystemCallError, SocketError, URI::InvalidURIError => error
134
+ @output.puts "Ruby download failed: #{error.message}"
135
+ false
136
+ end
137
+
138
+ def extract_zip(archive_path, destination)
139
+ return if extract_with_rubyzip(archive_path, destination)
140
+ return if windows? && extract_with_powershell(archive_path, destination)
141
+ return if extract_with_unzip(archive_path, destination)
142
+
143
+ raise InstallError, "Failed to extract zip; install the rubyzip gem or a supported system extractor"
144
+ end
145
+
146
+ def extract_with_rubyzip(archive_path, destination)
147
+ require "zip"
148
+ destination_root = "#{File.expand_path(destination)}#{File::SEPARATOR}"
149
+
150
+ Zip::File.open(archive_path) do |zip_file|
151
+ zip_file.each do |entry|
152
+ target = File.expand_path(entry.name, destination)
153
+ unless target.start_with?(destination_root)
154
+ raise InstallError, "Archive entry escapes destination: #{entry.name}"
155
+ end
156
+
157
+ FileUtils.mkdir_p(entry.directory? ? target : File.dirname(target))
158
+ entry.extract(target) { true } unless entry.directory?
159
+ end
160
+ end
161
+ true
162
+ rescue LoadError
163
+ false
164
+ end
165
+
166
+ def extract_with_powershell(archive_path, destination)
167
+ system(
168
+ "powershell",
169
+ "-NoProfile",
170
+ "-Command",
171
+ "Expand-Archive -Force -LiteralPath '#{archive_path}' -DestinationPath '#{destination}'"
172
+ )
173
+ end
174
+
175
+ def extract_with_unzip(archive_path, destination)
176
+ system("unzip", "-o", "-q", archive_path, "-d", destination)
177
+ end
178
+
179
+ def windows?
180
+ /mingw|mswin/.match?(platform)
181
+ end
182
+
183
+ def recovery_instructions
184
+ <<~INSTRUCTIONS.chomp
185
+ Download the matching #{Distribution::VERSION} artifact manually and set:
186
+ WGPU_LIB_PATH=/absolute/path/to/the/wgpu-native/shared-library
187
+ See docs/installation.md for platform details.
188
+ INSTRUCTIONS
189
+ end
190
+ end
191
+ end
192
+ end
@@ -1,53 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ffi"
4
- require "rbconfig"
4
+ require_relative "distribution"
5
5
 
6
6
  module WGPU
7
7
  module Native
8
- extend FFI::Library
8
+ module OptionalFunctions
9
+ def attach_optional_function(name, arguments, result)
10
+ attach_function(name, arguments, result)
11
+ optional_functions[name] = true
12
+ rescue FFI::NotFoundError
13
+ optional_functions[name] = false
14
+ define_singleton_method(name) do |*|
15
+ raise WGPU::Error,
16
+ "Optional wgpu-native function #{name} is unavailable in the loaded library " \
17
+ "(expected #{Distribution::VERSION})"
18
+ end
19
+ end
20
+
21
+ def optional_function_available?(name)
22
+ optional_functions.fetch(name, false)
23
+ end
24
+
25
+ def optional_capabilities
26
+ optional_functions.dup.freeze
27
+ end
28
+
29
+ private
9
30
 
10
- WGPU_VERSION = "v27.0.4.0"
31
+ def optional_functions
32
+ @optional_functions ||= {}
33
+ end
34
+ end
35
+
36
+ extend FFI::Library
37
+ extend OptionalFunctions
11
38
 
12
39
  class << self
13
40
  def library_path
14
- if ENV["WGPU_LIB_PATH"]
15
- path = ENV["WGPU_LIB_PATH"]
41
+ if ENV["WGPU_LIB_PATH"] && !ENV["WGPU_LIB_PATH"].empty?
42
+ path = File.expand_path(ENV["WGPU_LIB_PATH"])
16
43
  raise LoadError, "WGPU_LIB_PATH points to non-existent file: #{path}" unless File.exist?(path)
17
44
  return path
18
45
  end
19
46
 
20
- cached_path = File.join(cache_dir, "lib", library_name)
21
- return cached_path if File.exist?(cached_path)
47
+ cached_path = Distribution.library_paths.find { |path| File.file?(path) }
48
+ return cached_path if cached_path
22
49
 
23
50
  raise LoadError, <<~MSG
24
51
  wgpu-native library not found.
25
- Expected at: #{cached_path}
52
+ Searched:
53
+ #{Distribution.library_paths.join("\n ")}
26
54
 
27
55
  Try reinstalling the gem:
28
56
  gem install wgpu
29
57
 
30
58
  Or set WGPU_LIB_PATH environment variable to your custom wgpu-native build.
59
+ See docs/installation.md for manual installation instructions.
31
60
  MSG
32
61
  end
33
62
 
34
63
  def cache_dir
35
- File.join(Dir.home, ".cache", "wgpu-ruby", WGPU_VERSION)
64
+ Distribution.primary_cache_dir
36
65
  end
37
66
 
38
67
  def library_name
39
- case host_os
40
- when /linux/ then "libwgpu_native.so"
41
- when /darwin/ then "libwgpu_native.dylib"
42
- when /mingw|mswin/ then "wgpu_native.dll"
43
- else raise LoadError, "Unsupported OS: #{host_os}"
44
- end
45
- end
46
-
47
- private
48
-
49
- def host_os
50
- RbConfig::CONFIG["host_os"]
68
+ Distribution.artifact_for.fetch(:library)
51
69
  end
52
70
  end
53
71
 
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WGPU
4
+ class << self
5
+ attr_accessor :debug_leaks
6
+ end
7
+
8
+ self.debug_leaks = ENV["WGPU_DEBUG_LEAKS"] == "1"
9
+
10
+ module LeakTracker
11
+ @resources = {}
12
+ @mutex = Mutex.new
13
+
14
+ module_function
15
+
16
+ def register(resource)
17
+ return unless WGPU.debug_leaks
18
+
19
+ object_id = resource.object_id
20
+ description = describe(resource)
21
+ @mutex.synchronize { @resources[object_id] = description }
22
+ ObjectSpace.define_finalizer(resource, finalizer(object_id))
23
+ end
24
+
25
+ def unregister(resource)
26
+ @mutex.synchronize { @resources.delete(resource.object_id) }
27
+ end
28
+
29
+ def warn_remaining
30
+ resources = @mutex.synchronize do
31
+ remaining = @resources.values
32
+ @resources.clear
33
+ remaining
34
+ end
35
+ resources.each { |description| warn "WGPU resource leaked: #{description}" }
36
+ end
37
+
38
+ def describe(resource)
39
+ label = resource.label
40
+ label_text = label ? " label=#{label.inspect}" : ""
41
+ "#{resource.class}#{label_text}"
42
+ end
43
+ private_class_method :describe
44
+
45
+ def finalizer(object_id)
46
+ proc do
47
+ description = @mutex.synchronize { @resources.delete(object_id) }
48
+ warn "WGPU resource leaked: #{description}" if description
49
+ end
50
+ end
51
+ private_class_method :finalizer
52
+ end
53
+
54
+ at_exit { LeakTracker.warn_remaining if WGPU.debug_leaks }
55
+
56
+ module CallbackKeepalive
57
+ INITIALIZATION_MUTEX = Mutex.new
58
+
59
+ module_function
60
+
61
+ def retain(owner, callback)
62
+ mutex, callbacks = storage_for(owner)
63
+ token = Object.new
64
+ mutex.synchronize { callbacks[token] = callback }
65
+ token
66
+ end
67
+
68
+ def release(owner, token)
69
+ return unless token
70
+
71
+ mutex, callbacks = storage_for(owner)
72
+ mutex.synchronize { callbacks.delete(token) }
73
+ end
74
+
75
+ def count(owner)
76
+ mutex, callbacks = storage_for(owner)
77
+ mutex.synchronize { callbacks.length }
78
+ end
79
+
80
+ def storage_for(owner)
81
+ INITIALIZATION_MUTEX.synchronize do
82
+ mutex = owner.instance_variable_get(:@wgpu_callback_keepalive_mutex)
83
+ callbacks = owner.instance_variable_get(:@wgpu_callback_keepalive)
84
+ unless mutex && callbacks
85
+ mutex = Mutex.new
86
+ callbacks = {}
87
+ owner.instance_variable_set(:@wgpu_callback_keepalive_mutex, mutex)
88
+ owner.instance_variable_set(:@wgpu_callback_keepalive, callbacks)
89
+ end
90
+ [mutex, callbacks]
91
+ end
92
+ end
93
+ private_class_method :storage_for
94
+ end
95
+
96
+ module NativeResource
97
+ GUARDED_METHOD_EXEMPTIONS = [:initialize, :release, :released?, :handle, :label, :inspect].freeze
98
+
99
+ module Lifecycle
100
+ def initialize(*args, **kwargs, &block)
101
+ super
102
+ @released = false
103
+ @label = kwargs[:label] if kwargs.key?(:label)
104
+ LeakTracker.register(self)
105
+ end
106
+
107
+ def release(...)
108
+ return if released?
109
+
110
+ result = super
111
+ @released = true
112
+ LeakTracker.unregister(self)
113
+ result
114
+ end
115
+ end
116
+
117
+ def self.included(base)
118
+ guard = Module.new
119
+ base.public_instance_methods(false).each do |method_name|
120
+ next if GUARDED_METHOD_EXEMPTIONS.include?(method_name)
121
+
122
+ guard.define_method(method_name) do |*args, **kwargs, &block|
123
+ ensure_not_released!
124
+ super(*args, **kwargs, &block)
125
+ end
126
+ end
127
+ base.prepend(guard)
128
+ base.prepend(Lifecycle)
129
+ end
130
+
131
+ attr_reader :label
132
+
133
+ def released?
134
+ return true if @released
135
+ return false unless instance_variable_defined?(:@handle)
136
+ return true if @handle.nil?
137
+
138
+ @handle.respond_to?(:null?) && @handle.null?
139
+ end
140
+
141
+ # Yields this wrapper and always releases it when the block exits.
142
+ #
143
+ # This is Ruby convenience API; WebGPU itself has no block-scoped resource
144
+ # primitive.
145
+ #
146
+ # @yieldparam resource [NativeResource] this resource
147
+ # @return the block result
148
+ def use
149
+ raise ArgumentError, "block is required" unless block_given?
150
+
151
+ ensure_not_released!
152
+ yield self
153
+ ensure
154
+ release if block_given? && !released?
155
+ end
156
+
157
+ def inspect
158
+ label_text = @label ? " label=#{@label.inspect}" : ""
159
+ "#<#{self.class}#{label_text} released=#{released?}>"
160
+ end
161
+
162
+ private
163
+
164
+ def ensure_not_released!
165
+ return unless released?
166
+
167
+ label_text = @label ? " (#{@label})" : ""
168
+ raise ResourceError, "#{self.class}#{label_text} has been released"
169
+ end
170
+ end
171
+ end
@@ -47,6 +47,18 @@ module WGPU
47
47
  private
48
48
 
49
49
  def create_entry(entry_hash)
50
+ DescriptorHelpers.validate_keys!(
51
+ entry_hash,
52
+ allowed: %i[binding buffer offset size sampler texture_view],
53
+ required: [:binding],
54
+ context: "bind group entry"
55
+ )
56
+ resources = %i[buffer sampler texture_view].select { |key| entry_hash[key] }
57
+ unless resources.one?
58
+ raise ArgumentError,
59
+ "bind group entry must define exactly one resource (:buffer, :sampler, or :texture_view)"
60
+ end
61
+
50
62
  entry = Native::BindGroupEntry.new
51
63
  entry[:next_in_chain] = nil
52
64
  entry[:binding] = entry_hash[:binding]
@@ -13,30 +13,12 @@ module WGPU
13
13
 
14
14
  def initialize(device, label: nil, entries:)
15
15
  @device = device
16
-
17
- entries_array = entries.map { |e| create_entry(e) }
18
- entries_ptr = FFI::MemoryPointer.new(Native::BindGroupLayoutEntry, entries_array.size)
19
- entries_array.each_with_index do |entry, i|
20
- offset = i * Native::BindGroupLayoutEntry.size
21
- (entries_ptr + offset).put_bytes(0, entry.pointer.read_bytes(Native::BindGroupLayoutEntry.size))
22
- end
23
-
24
- desc = Native::BindGroupLayoutDescriptor.new
25
- desc[:next_in_chain] = nil
26
- if label
27
- label_ptr = FFI::MemoryPointer.from_string(label)
28
- desc[:label][:data] = label_ptr
29
- desc[:label][:length] = label.bytesize
30
- else
31
- desc[:label][:data] = nil
32
- desc[:label][:length] = 0
33
- end
34
- desc[:entry_count] = entries_array.size
35
- desc[:entries] = entries_ptr
16
+ desc, @descriptor_keepalive = build_descriptor(label:, entries:)
36
17
 
37
18
  device.push_error_scope(:validation)
38
19
  @handle = Native.wgpuDeviceCreateBindGroupLayout(device.handle, desc)
39
20
  error = device.pop_error_scope
21
+ @descriptor_keepalive = nil
40
22
 
41
23
  if @handle.null? || (error[:type] && error[:type] != :no_error)
42
24
  msg = error[:message] || "Failed to create bind group layout"
@@ -52,7 +34,39 @@ module WGPU
52
34
 
53
35
  private
54
36
 
37
+ def build_descriptor(label:, entries:)
38
+ keepalive = []
39
+ entries_array = entries.map { |entry| create_entry(entry) }
40
+ entries_ptr = FFI::MemoryPointer.new(Native::BindGroupLayoutEntry, entries_array.size)
41
+ entries_array.each_with_index do |entry, index|
42
+ offset = index * Native::BindGroupLayoutEntry.size
43
+ (entries_ptr + offset).put_bytes(0, entry.pointer.read_bytes(Native::BindGroupLayoutEntry.size))
44
+ end
45
+ keepalive.concat(entries_array)
46
+ keepalive << entries_ptr
47
+
48
+ desc = Native::BindGroupLayoutDescriptor.new
49
+ desc[:next_in_chain] = nil
50
+ DescriptorHelpers.set_label(desc, label, keepalive:)
51
+ desc[:entry_count] = entries_array.size
52
+ desc[:entries] = entries_ptr
53
+ [desc, keepalive]
54
+ end
55
+
55
56
  def create_entry(entry_hash)
57
+ DescriptorHelpers.validate_keys!(
58
+ entry_hash,
59
+ allowed: %i[binding visibility buffer sampler texture storage_texture],
60
+ required: %i[binding visibility],
61
+ context: "bind group layout entry"
62
+ )
63
+ variants = %i[buffer sampler texture storage_texture].select { |key| entry_hash[key] }
64
+ unless variants.one?
65
+ raise ArgumentError,
66
+ "bind group layout entry must define exactly one resource variant " \
67
+ "(:buffer, :sampler, :texture, or :storage_texture)"
68
+ end
69
+
56
70
  entry = Native::BindGroupLayoutEntry.new
57
71
  entry[:next_in_chain] = nil
58
72
  entry[:binding] = entry_hash[:binding]
@@ -78,44 +92,84 @@ module WGPU
78
92
 
79
93
  if entry_hash[:buffer]
80
94
  buffer_info = entry_hash[:buffer]
81
- entry[:buffer][:type] = buffer_info[:type] || :storage
95
+ DescriptorHelpers.validate_keys!(
96
+ buffer_info,
97
+ allowed: %i[type has_dynamic_offset min_binding_size],
98
+ context: "buffer binding layout"
99
+ )
100
+ entry[:buffer][:type] = Native::EnumHelper.coerce(
101
+ Native::BufferBindingType,
102
+ buffer_info[:type] || :storage,
103
+ name: "buffer binding type"
104
+ )
82
105
  entry[:buffer][:has_dynamic_offset] = buffer_info[:has_dynamic_offset] ? 1 : 0
83
106
  entry[:buffer][:min_binding_size] = buffer_info[:min_binding_size] || 0
84
107
  end
85
108
 
86
109
  if entry_hash[:sampler]
87
110
  sampler_info = entry_hash[:sampler]
88
- entry[:sampler][:type] = sampler_info[:type] || :filtering
111
+ DescriptorHelpers.validate_keys!(
112
+ sampler_info,
113
+ allowed: [:type],
114
+ context: "sampler binding layout"
115
+ )
116
+ entry[:sampler][:type] = Native::EnumHelper.coerce(
117
+ Native::SamplerBindingType,
118
+ sampler_info[:type] || :filtering,
119
+ name: "sampler binding type"
120
+ )
89
121
  end
90
122
 
91
123
  if entry_hash[:texture]
92
124
  texture_info = entry_hash[:texture]
93
- entry[:texture][:sample_type] = texture_info[:sample_type] || :float
94
- entry[:texture][:view_dimension] = texture_info[:view_dimension] || :d2
125
+ DescriptorHelpers.validate_keys!(
126
+ texture_info,
127
+ allowed: %i[sample_type view_dimension multisampled],
128
+ context: "texture binding layout"
129
+ )
130
+ entry[:texture][:sample_type] = Native::EnumHelper.coerce(
131
+ Native::TextureSampleType,
132
+ texture_info[:sample_type] || :float,
133
+ name: "texture sample type"
134
+ )
135
+ entry[:texture][:view_dimension] = Native::EnumHelper.coerce(
136
+ Native::TextureViewDimension,
137
+ texture_info[:view_dimension] || :d2,
138
+ name: "texture view dimension"
139
+ )
95
140
  entry[:texture][:multisampled] = texture_info[:multisampled] ? 1 : 0
96
141
  end
97
142
 
98
143
  if entry_hash[:storage_texture]
99
144
  st_info = entry_hash[:storage_texture]
100
- entry[:storage_texture][:access] = st_info[:access] || :write_only
101
- entry[:storage_texture][:format] = st_info[:format]
102
- entry[:storage_texture][:view_dimension] = st_info[:view_dimension] || :d2
145
+ DescriptorHelpers.validate_keys!(
146
+ st_info,
147
+ allowed: %i[access format view_dimension],
148
+ required: [:format],
149
+ context: "storage texture binding layout"
150
+ )
151
+ entry[:storage_texture][:access] = Native::EnumHelper.coerce(
152
+ Native::StorageTextureAccess,
153
+ st_info[:access] || :write_only,
154
+ name: "storage texture access"
155
+ )
156
+ entry[:storage_texture][:format] = Native::EnumHelper.coerce(
157
+ Native::TextureFormat,
158
+ st_info.fetch(:format),
159
+ name: "storage texture format"
160
+ )
161
+ entry[:storage_texture][:view_dimension] = Native::EnumHelper.coerce(
162
+ Native::TextureViewDimension,
163
+ st_info[:view_dimension] || :d2,
164
+ name: "texture view dimension"
165
+ )
103
166
  end
104
167
 
105
168
  entry
106
169
  end
107
170
 
108
171
  def normalize_visibility(visibility)
109
- case visibility
110
- when Integer
111
- visibility
112
- when Symbol
113
- Native::ShaderStage[visibility]
114
- when Array
115
- visibility.reduce(0) { |acc, v| acc | Native::ShaderStage[v] }
116
- else
117
- raise ArgumentError, "Invalid visibility: #{visibility}"
118
- end
172
+ Native::EnumHelper.coerce_flags(Native::ShaderStage, visibility, name: "shader visibility")
119
173
  end
120
174
  end
121
175
  end