texel 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +219 -0
- data/Rakefile +35 -0
- data/ext/texel/extconf.rb +28 -0
- data/ext/texel/shim.c +244 -0
- data/ext/texel/shim.h +35 -0
- data/ext/texel/texel_native.c +415 -0
- data/ext/texel/vendor/README.md +11 -0
- data/ext/texel/vendor/stb_image.h +7988 -0
- data/ext/texel/vendor/stb_image_resize2.h +10679 -0
- data/ext/texel/vendor/stb_image_write.h +1724 -0
- data/lib/texel/codec.rb +27 -0
- data/lib/texel/codecs/qoi.rb +228 -0
- data/lib/texel/codecs/stb.rb +104 -0
- data/lib/texel/color_transform.rb +63 -0
- data/lib/texel/errors.rb +9 -0
- data/lib/texel/gl.rb +36 -0
- data/lib/texel/half_float.rb +57 -0
- data/lib/texel/image.rb +106 -0
- data/lib/texel/image_transformations.rb +115 -0
- data/lib/texel/info.rb +10 -0
- data/lib/texel/native.rb +91 -0
- data/lib/texel/pixel_data.rb +79 -0
- data/lib/texel/registry.rb +66 -0
- data/lib/texel/resampler.rb +79 -0
- data/lib/texel/source_reader.rb +52 -0
- data/lib/texel/version.rb +5 -0
- data/lib/texel/wgpu.rb +138 -0
- data/lib/texel.rb +164 -0
- metadata +75 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Texel
|
|
4
|
+
module ImageTransformations
|
|
5
|
+
FILTERS = %i[box triangle cubic mitchell lanczos].freeze
|
|
6
|
+
EDGES = %i[clamp reflect wrap zero].freeze
|
|
7
|
+
|
|
8
|
+
def flip_y
|
|
9
|
+
flipped = String.new(capacity: data.bytesize, encoding: Encoding::BINARY)
|
|
10
|
+
(height - 1).downto(0) { |row| flipped << data.byteslice(row * row_bytes, row_bytes) }
|
|
11
|
+
dup_with(data: flipped)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def convert(channels: self.channels, dtype: self.dtype)
|
|
15
|
+
validate_conversion!(channels, dtype)
|
|
16
|
+
return self if channels == self.channels && dtype == self.dtype
|
|
17
|
+
|
|
18
|
+
values = PixelData.unpack(data, self.dtype)
|
|
19
|
+
values = PixelData.convert_channels(values, width * height, self.channels, channels, self.dtype)
|
|
20
|
+
values = PixelData.convert_dtype(values, self.dtype, dtype)
|
|
21
|
+
dup_with(data: PixelData.pack(values, dtype), channels: channels, dtype: dtype)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def resize(new_width, new_height, filter: :mitchell, edge: :clamp)
|
|
25
|
+
validate_resize!(new_width, new_height, filter, edge)
|
|
26
|
+
return self if new_width == width && new_height == height
|
|
27
|
+
|
|
28
|
+
Resampler.resize(self, new_width, new_height, filter: filter, edge: edge)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resize_to_fit(maximum)
|
|
32
|
+
raise FormatError, "maximum dimension must be a positive integer" unless maximum.is_a?(Integer) && maximum.positive?
|
|
33
|
+
|
|
34
|
+
scale = [maximum.to_f / width, maximum.to_f / height, 1.0].min
|
|
35
|
+
resize([(width * scale).round, 1].max, [(height * scale).round, 1].max)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_linear
|
|
39
|
+
ColorTransform.linearize(self)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_srgb
|
|
43
|
+
ColorTransform.delinearize(self)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def premultiply
|
|
47
|
+
transform_alpha(:premultiply)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unpremultiply
|
|
51
|
+
transform_alpha(:unpremultiply)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mipmaps(fast: false)
|
|
55
|
+
levels = [self]
|
|
56
|
+
levels << next_mipmap(levels.last, fast: fast) until levels.last.width == 1 && levels.last.height == 1
|
|
57
|
+
levels
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def next_mipmap(source, fast:)
|
|
63
|
+
target_width = [source.width / 2, 1].max
|
|
64
|
+
target_height = [source.height / 2, 1].max
|
|
65
|
+
if fast && source.color_space == :srgb
|
|
66
|
+
encoded = source.dup_with(color_space: :unknown).resize(target_width, target_height, filter: :box)
|
|
67
|
+
return encoded.dup_with(color_space: :srgb)
|
|
68
|
+
end
|
|
69
|
+
return source.resize(target_width, target_height, filter: :box) if source.color_space != :srgb
|
|
70
|
+
|
|
71
|
+
linear = source.to_linear.resize(target_width, target_height, filter: :box)
|
|
72
|
+
ColorTransform.delinearize(linear, dtype: source.dtype)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def transform_alpha(operation)
|
|
76
|
+
unless [2, 4].include?(channels)
|
|
77
|
+
raise FormatError, "#{operation} requires an image with 2 or 4 channels"
|
|
78
|
+
end
|
|
79
|
+
return self if (operation == :premultiply) == premultiplied?
|
|
80
|
+
|
|
81
|
+
values = PixelData.unpack(data, dtype)
|
|
82
|
+
maximum = PixelData::MAX_VALUES.fetch(dtype).to_f
|
|
83
|
+
(width * height).times do |pixel|
|
|
84
|
+
offset = pixel * channels
|
|
85
|
+
alpha = values[offset + channels - 1].to_f / maximum
|
|
86
|
+
(channels - 1).times do |channel|
|
|
87
|
+
value = operation == :premultiply ? values[offset + channel] * alpha : divide_alpha(values[offset + channel], alpha)
|
|
88
|
+
values[offset + channel] = PixelData.cast(value, dtype)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
dup_with(data: PixelData.pack(values, dtype), premultiplied: operation == :premultiply)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def divide_alpha(value, alpha)
|
|
95
|
+
alpha.zero? ? 0.0 : value / alpha
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_conversion!(target_channels, target_dtype)
|
|
99
|
+
raise FormatError, "channels must be between 1 and 4" unless target_channels.is_a?(Integer) && target_channels.between?(1, 4)
|
|
100
|
+
return if Image::DTYPE_SIZES.key?(target_dtype)
|
|
101
|
+
|
|
102
|
+
raise FormatError, "unsupported dtype #{target_dtype.inspect}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_resize!(target_width, target_height, filter, edge)
|
|
106
|
+
unless target_width.is_a?(Integer) && target_width.positive? && target_height.is_a?(Integer) && target_height.positive?
|
|
107
|
+
raise FormatError, "resize dimensions must be positive integers"
|
|
108
|
+
end
|
|
109
|
+
raise FormatError, "unsupported resize filter #{filter.inspect}" unless FILTERS.include?(filter)
|
|
110
|
+
raise FormatError, "unsupported edge mode #{edge.inspect}" unless EDGES.include?(edge)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Image.include(ImageTransformations)
|
|
115
|
+
end
|
data/lib/texel/info.rb
ADDED
data/lib/texel/native.rb
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Texel
|
|
4
|
+
module Native
|
|
5
|
+
class OperationError < StandardError; end
|
|
6
|
+
|
|
7
|
+
@available = false
|
|
8
|
+
@load_error = nil
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :load_error
|
|
12
|
+
|
|
13
|
+
def available?
|
|
14
|
+
@available
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def metadata(bytes)
|
|
18
|
+
call_bridge(:metadata, bytes)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def decode(bytes, channels, dtype)
|
|
22
|
+
call_bridge(:decode, bytes, channels || 0, dtype_code(dtype))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def encode(data, format, width, height, channels, dtype, quality)
|
|
26
|
+
call_bridge(
|
|
27
|
+
:encode, data, format_code(format), width, height, channels, dtype_code(dtype), Integer(quality)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def resize(data, source_width, source_height, width, height, channels, dtype, srgb, filter, edge, premultiplied)
|
|
32
|
+
call_bridge(
|
|
33
|
+
:resize, data, source_width, source_height, width, height, channels, dtype_code(dtype),
|
|
34
|
+
srgb, filter, edge, premultiplied
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def pack_f16(data)
|
|
39
|
+
call_bridge(:pack_f16, data)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def call_bridge(method, *arguments)
|
|
45
|
+
raise OperationError, native_unavailable_message unless available?
|
|
46
|
+
|
|
47
|
+
NativeBridge.public_send(method, *arguments)
|
|
48
|
+
rescue RuntimeError => e
|
|
49
|
+
raise OperationError, e.message
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def native_unavailable_message
|
|
53
|
+
detail = load_error&.message
|
|
54
|
+
["native extension is unavailable", detail].compact.join(": ")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dtype_code(dtype)
|
|
58
|
+
{ u8: 0, u16: 1, f32: 2 }.fetch(dtype)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def format_code(format)
|
|
62
|
+
{ png: 0, jpg: 1, jpeg: 1, bmp: 2, tga: 3, hdr: 4 }.fetch(format)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
unless ENV["TEXEL_DISABLE_NATIVE"] == "1"
|
|
67
|
+
load_errors = []
|
|
68
|
+
ruby_abi = RUBY_VERSION.split(".").first(2).join(".")
|
|
69
|
+
|
|
70
|
+
["texel/#{ruby_abi}/texel_native", "texel/texel_native"].each do |extension|
|
|
71
|
+
begin
|
|
72
|
+
require extension
|
|
73
|
+
@available = true
|
|
74
|
+
break
|
|
75
|
+
rescue LoadError => error
|
|
76
|
+
load_errors << error
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
unless @available
|
|
81
|
+
begin
|
|
82
|
+
require_relative "../../ext/texel/texel_native"
|
|
83
|
+
@available = true
|
|
84
|
+
rescue LoadError => development_error
|
|
85
|
+
@load_error = development_error
|
|
86
|
+
@load_error.set_backtrace(load_errors.first&.backtrace || development_error.backtrace)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Texel
|
|
4
|
+
module PixelData
|
|
5
|
+
MAX_VALUES = { u8: 255, u16: 65_535, f32: 1.0 }.freeze
|
|
6
|
+
UNPACK_DIRECTIVES = { u8: "C*", u16: "S<*", f32: "e*" }.freeze
|
|
7
|
+
PACK_DIRECTIVES = { u8: "C*", u16: "S<*", f32: "e*" }.freeze
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def unpack(data, dtype)
|
|
12
|
+
data.unpack(UNPACK_DIRECTIVES.fetch(dtype))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def pack(values, dtype)
|
|
16
|
+
values.pack(PACK_DIRECTIVES.fetch(dtype)).b
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalize(value, dtype)
|
|
20
|
+
value.to_f / MAX_VALUES.fetch(dtype)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def denormalize(value, dtype)
|
|
24
|
+
maximum = MAX_VALUES.fetch(dtype)
|
|
25
|
+
clamped = value.clamp(0.0, 1.0)
|
|
26
|
+
return clamped if dtype == :f32
|
|
27
|
+
|
|
28
|
+
(clamped * maximum).round
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cast(value, dtype)
|
|
32
|
+
return value.to_f if dtype == :f32
|
|
33
|
+
|
|
34
|
+
value.round.clamp(0, MAX_VALUES.fetch(dtype))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def convert_dtype(values, source, target)
|
|
38
|
+
return values if source == target
|
|
39
|
+
|
|
40
|
+
values.map { |value| denormalize(normalize(value, source), target) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def convert_channels(values, pixel_count, source_channels, target_channels, dtype)
|
|
44
|
+
return values if source_channels == target_channels
|
|
45
|
+
|
|
46
|
+
maximum = MAX_VALUES.fetch(dtype)
|
|
47
|
+
result = Array.new(pixel_count * target_channels)
|
|
48
|
+
pixel_count.times do |pixel_index|
|
|
49
|
+
source = values.slice(pixel_index * source_channels, source_channels)
|
|
50
|
+
target = channel_values(source, source_channels, target_channels, maximum, dtype)
|
|
51
|
+
result[pixel_index * target_channels, target_channels] = target
|
|
52
|
+
end
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def channel_values(source, source_channels, target_channels, maximum, dtype)
|
|
57
|
+
red, green, blue, alpha = rgba_values(source, source_channels, maximum)
|
|
58
|
+
gray = cast((red * 0.299) + (green * 0.587) + (blue * 0.114), dtype)
|
|
59
|
+
|
|
60
|
+
case target_channels
|
|
61
|
+
when 1 then [gray]
|
|
62
|
+
when 2 then [gray, alpha]
|
|
63
|
+
when 3 then [red, green, blue]
|
|
64
|
+
when 4 then [red, green, blue, alpha]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
private_class_method :channel_values
|
|
68
|
+
|
|
69
|
+
def rgba_values(source, source_channels, maximum)
|
|
70
|
+
case source_channels
|
|
71
|
+
when 1 then [source[0], source[0], source[0], maximum]
|
|
72
|
+
when 2 then [source[0], source[0], source[0], source[1]]
|
|
73
|
+
when 3 then [source[0], source[1], source[2], maximum]
|
|
74
|
+
when 4 then source
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
private_class_method :rgba_values
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Texel
|
|
4
|
+
module Registry
|
|
5
|
+
Entry = Data.define(:format, :magic, :codec, :extensions, :required_gem)
|
|
6
|
+
|
|
7
|
+
@entries = {}
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def register(format, magic:, codec:, extensions: [format.to_s], required_gem: nil)
|
|
11
|
+
normalized = format.to_sym
|
|
12
|
+
@entries[normalized] = Entry.new(
|
|
13
|
+
format: normalized,
|
|
14
|
+
magic: magic,
|
|
15
|
+
codec: codec,
|
|
16
|
+
extensions: Array(extensions).map { |extension| extension.to_s.downcase }.freeze,
|
|
17
|
+
required_gem: required_gem
|
|
18
|
+
).freeze
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch(format)
|
|
22
|
+
@entries[format.to_sym]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def formats
|
|
26
|
+
@entries.keys.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def detect(bytes, extension: nil)
|
|
30
|
+
detected = @entries.values.find { |entry| magic_matches?(entry.magic, bytes) }
|
|
31
|
+
return detected.format if detected
|
|
32
|
+
|
|
33
|
+
by_extension(extension)&.format
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def codec_for!(format, operation:)
|
|
37
|
+
entry = fetch(format)
|
|
38
|
+
raise FormatError, "unsupported image format #{format.inspect}" unless entry
|
|
39
|
+
if entry.required_gem && !entry.codec
|
|
40
|
+
raise MissingCodecError, "#{entry.format.to_s.upcase} requires the #{entry.required_gem} gem"
|
|
41
|
+
end
|
|
42
|
+
unless entry.codec&.public_send("can_#{operation}?", entry.format)
|
|
43
|
+
raise MissingCodecError, "#{entry.format.to_s.upcase} #{operation} support is unavailable"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
entry.codec
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def magic_matches?(magic, bytes)
|
|
52
|
+
return false unless magic
|
|
53
|
+
return magic.call(bytes) if magic.respond_to?(:call)
|
|
54
|
+
|
|
55
|
+
Array(magic).any? { |signature| bytes.start_with?(signature.b) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def by_extension(extension)
|
|
59
|
+
return unless extension
|
|
60
|
+
|
|
61
|
+
normalized = extension.to_s.delete_prefix(".").downcase
|
|
62
|
+
@entries.values.find { |entry| entry.extensions.include?(normalized) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Texel
|
|
4
|
+
module Resampler
|
|
5
|
+
FILTER_CODES = { box: 1, triangle: 2, cubic: 3, mitchell: 5, lanczos: 7 }.freeze
|
|
6
|
+
EDGE_CODES = { clamp: 0, reflect: 1, wrap: 2, zero: 3 }.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def resize(image, width, height, filter:, edge:)
|
|
11
|
+
if encoded_srgb_requires_linear_path?(image)
|
|
12
|
+
linear = ColorTransform.linearize(image)
|
|
13
|
+
resized = resize(linear, width, height, filter: filter, edge: edge)
|
|
14
|
+
return ColorTransform.delinearize(resized, dtype: image.dtype)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
if Native.available?
|
|
18
|
+
data = Native.resize(
|
|
19
|
+
image.data, image.width, image.height, width, height, image.channels, image.dtype,
|
|
20
|
+
image.color_space == :srgb, FILTER_CODES.fetch(filter), EDGE_CODES.fetch(edge), image.premultiplied?
|
|
21
|
+
)
|
|
22
|
+
return image.dup_with(data: data, width: width, height: height)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
resize_in_ruby(image, width, height)
|
|
26
|
+
rescue Native::OperationError => e
|
|
27
|
+
raise FormatError, "resize failed: #{e.message}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def encoded_srgb_requires_linear_path?(image)
|
|
31
|
+
image.color_space == :srgb && (image.dtype != :u8 || !Native.available?)
|
|
32
|
+
end
|
|
33
|
+
private_class_method :encoded_srgb_requires_linear_path?
|
|
34
|
+
|
|
35
|
+
def resize_in_ruby(image, target_width, target_height)
|
|
36
|
+
source = PixelData.unpack(image.data, image.dtype)
|
|
37
|
+
output = Array.new(target_width * target_height * image.channels)
|
|
38
|
+
target_height.times do |target_y|
|
|
39
|
+
source_y = (((target_y + 0.5) * image.height / target_height) - 0.5).clamp(0, image.height - 1)
|
|
40
|
+
y0, y1, vertical = interpolation_points(source_y, image.height)
|
|
41
|
+
target_width.times do |target_x|
|
|
42
|
+
source_x = (((target_x + 0.5) * image.width / target_width) - 0.5).clamp(0, image.width - 1)
|
|
43
|
+
x0, x1, horizontal = interpolation_points(source_x, image.width)
|
|
44
|
+
image.channels.times do |channel|
|
|
45
|
+
output[((target_y * target_width) + target_x) * image.channels + channel] = interpolate(
|
|
46
|
+
source, image, x0, x1, y0, y1, channel, horizontal, vertical
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
image.dup_with(data: PixelData.pack(output, image.dtype), width: target_width, height: target_height)
|
|
52
|
+
end
|
|
53
|
+
private_class_method :resize_in_ruby
|
|
54
|
+
|
|
55
|
+
def interpolation_points(position, size)
|
|
56
|
+
lower = position.floor
|
|
57
|
+
upper = [lower + 1, size - 1].min
|
|
58
|
+
[lower, upper, position - lower]
|
|
59
|
+
end
|
|
60
|
+
private_class_method :interpolation_points
|
|
61
|
+
|
|
62
|
+
def interpolate(source, image, x0, x1, y0, y1, channel, horizontal, vertical)
|
|
63
|
+
top = mix(sample(source, image, x0, y0, channel), sample(source, image, x1, y0, channel), horizontal)
|
|
64
|
+
bottom = mix(sample(source, image, x0, y1, channel), sample(source, image, x1, y1, channel), horizontal)
|
|
65
|
+
PixelData.cast(mix(top, bottom, vertical), image.dtype)
|
|
66
|
+
end
|
|
67
|
+
private_class_method :interpolate
|
|
68
|
+
|
|
69
|
+
def sample(source, image, x, y, channel)
|
|
70
|
+
source[((y * image.width) + x) * image.channels + channel]
|
|
71
|
+
end
|
|
72
|
+
private_class_method :sample
|
|
73
|
+
|
|
74
|
+
def mix(left, right, amount)
|
|
75
|
+
left + ((right - left) * amount)
|
|
76
|
+
end
|
|
77
|
+
private_class_method :mix
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Texel
|
|
4
|
+
module SourceReader
|
|
5
|
+
Source = Data.define(:bytes, :extension)
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def read(source)
|
|
10
|
+
return read_path(source) if path?(source)
|
|
11
|
+
return read_io(source) if source.respond_to?(:read)
|
|
12
|
+
return Source.new(bytes: immutable_binary(source), extension: nil) if source.is_a?(String)
|
|
13
|
+
|
|
14
|
+
raise FormatError, "source must be a file path, binary String, or readable IO"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def extension_for_path(path)
|
|
18
|
+
value = path.respond_to?(:to_path) ? path.to_path : path
|
|
19
|
+
File.extname(value).delete_prefix(".").downcase.then { |extension| extension unless extension.empty? }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def path?(source)
|
|
23
|
+
source.respond_to?(:to_path) || (source.is_a?(String) && File.file?(source))
|
|
24
|
+
rescue ArgumentError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
private_class_method :path?
|
|
28
|
+
|
|
29
|
+
def read_path(source)
|
|
30
|
+
path = source.respond_to?(:to_path) ? source.to_path : source
|
|
31
|
+
Source.new(bytes: File.binread(path).freeze, extension: extension_for_path(path))
|
|
32
|
+
rescue SystemCallError => e
|
|
33
|
+
raise FormatError, "could not read #{path}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
private_class_method :read_path
|
|
36
|
+
|
|
37
|
+
def read_io(io)
|
|
38
|
+
bytes = io.read
|
|
39
|
+
raise FormatError, "IO#read must return a String" unless bytes.is_a?(String)
|
|
40
|
+
|
|
41
|
+
Source.new(bytes: immutable_binary(bytes), extension: io.respond_to?(:path) ? extension_for_path(io.path) : nil)
|
|
42
|
+
end
|
|
43
|
+
private_class_method :read_io
|
|
44
|
+
|
|
45
|
+
def immutable_binary(bytes)
|
|
46
|
+
return bytes if bytes.encoding == Encoding::BINARY && bytes.frozen?
|
|
47
|
+
|
|
48
|
+
bytes.dup.force_encoding(Encoding::BINARY).freeze
|
|
49
|
+
end
|
|
50
|
+
private_class_method :immutable_binary
|
|
51
|
+
end
|
|
52
|
+
end
|
data/lib/texel/wgpu.rb
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../texel"
|
|
4
|
+
require_relative "half_float"
|
|
5
|
+
|
|
6
|
+
module Texel
|
|
7
|
+
module WGPU
|
|
8
|
+
ALIGNMENT = 256
|
|
9
|
+
FORMAT_ALIASES = {
|
|
10
|
+
r8unorm: :r8_unorm,
|
|
11
|
+
rg8unorm: :rg8_unorm,
|
|
12
|
+
rgba8unorm: :rgba8_unorm,
|
|
13
|
+
rgba8unorm_srgb: :rgba8_unorm_srgb,
|
|
14
|
+
r16float: :r16_float,
|
|
15
|
+
rg16float: :rg16_float,
|
|
16
|
+
rgba16float: :rgba16_float,
|
|
17
|
+
r32float: :r32_float,
|
|
18
|
+
rg32float: :rg32_float,
|
|
19
|
+
rgba32float: :rgba32_float
|
|
20
|
+
}.freeze
|
|
21
|
+
FORMAT_INFO = {
|
|
22
|
+
r8_unorm: { channels: 1, dtype: :u8, transfer: :u8 },
|
|
23
|
+
rg8_unorm: { channels: 2, dtype: :u8, transfer: :u8 },
|
|
24
|
+
rgba8_unorm: { channels: 4, dtype: :u8, transfer: :u8 },
|
|
25
|
+
rgba8_unorm_srgb: { channels: 4, dtype: :u8, transfer: :u8 },
|
|
26
|
+
r16_float: { channels: 1, dtype: :f32, transfer: :f16 },
|
|
27
|
+
rg16_float: { channels: 2, dtype: :f32, transfer: :f16 },
|
|
28
|
+
rgba16_float: { channels: 4, dtype: :f32, transfer: :f16 },
|
|
29
|
+
r32_float: { channels: 1, dtype: :f32, transfer: :f32 },
|
|
30
|
+
rg32_float: { channels: 2, dtype: :f32, transfer: :f32 },
|
|
31
|
+
rgba32_float: { channels: 4, dtype: :f32, transfer: :f32 }
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def upload(image, device:, queue:, usage: %i[texture_binding copy_dst], mips: true, format: nil, label: nil)
|
|
37
|
+
validate_image!(image)
|
|
38
|
+
texture_format = normalize_format(format || infer_format(image))
|
|
39
|
+
levels = mips ? image.mipmaps : [image]
|
|
40
|
+
texture = device.create_texture(
|
|
41
|
+
label: label,
|
|
42
|
+
size: extent(image),
|
|
43
|
+
format: texture_format,
|
|
44
|
+
usage: normalize_usage(usage),
|
|
45
|
+
mip_level_count: levels.length
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
levels.each_with_index do |level, mip_level|
|
|
49
|
+
data, bytes_per_row = pad_rows(level, format: texture_format)
|
|
50
|
+
queue.write_texture(
|
|
51
|
+
destination: { texture: texture, mip_level: mip_level, origin: { x: 0, y: 0, z: 0 } },
|
|
52
|
+
data: data,
|
|
53
|
+
data_layout: { offset: 0, bytes_per_row: bytes_per_row, rows_per_image: level.height },
|
|
54
|
+
size: extent(level)
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
texture
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def infer_format(image)
|
|
61
|
+
validate_image!(image)
|
|
62
|
+
if image.channels == 3
|
|
63
|
+
raise FormatError, "3-channel images are unsupported by WebGPU; convert to 4 channels first"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
case [image.dtype, image.channels, image.color_space]
|
|
67
|
+
in [:u8, 4, :srgb] then :rgba8_unorm_srgb
|
|
68
|
+
in [:u8, 4, _] then :rgba8_unorm
|
|
69
|
+
in [:u8, 1, _] then :r8_unorm
|
|
70
|
+
in [:u8, 2, _] then :rg8_unorm
|
|
71
|
+
in [:f32, 4, _] then :rgba16_float
|
|
72
|
+
in [:f32, 1, _] then :r16_float
|
|
73
|
+
in [:f32, 2, _] then :rg16_float
|
|
74
|
+
else
|
|
75
|
+
raise FormatError, "WebGPU upload does not support #{image.dtype} images with #{image.channels} channels"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def pad_rows(image, format: nil)
|
|
80
|
+
texture_format = normalize_format(format || infer_format(image))
|
|
81
|
+
info = validate_format!(image, texture_format)
|
|
82
|
+
transfer_data = transfer_data(image, info.fetch(:transfer))
|
|
83
|
+
component_size = { u8: 1, f16: 2, f32: 4 }.fetch(info.fetch(:transfer))
|
|
84
|
+
row_bytes = image.width * image.channels * component_size
|
|
85
|
+
bytes_per_row = align(row_bytes)
|
|
86
|
+
return [transfer_data.freeze, bytes_per_row] if row_bytes == bytes_per_row
|
|
87
|
+
|
|
88
|
+
padding = "\0".b * (bytes_per_row - row_bytes)
|
|
89
|
+
padded = String.new(capacity: bytes_per_row * image.height, encoding: Encoding::BINARY)
|
|
90
|
+
image.height.times do |row|
|
|
91
|
+
padded << transfer_data.byteslice(row * row_bytes, row_bytes) << padding
|
|
92
|
+
end
|
|
93
|
+
[padded.freeze, bytes_per_row]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def align(byte_count)
|
|
97
|
+
((byte_count + ALIGNMENT - 1) / ALIGNMENT) * ALIGNMENT
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def extent(image)
|
|
101
|
+
{ width: image.width, height: image.height, depth_or_array_layers: 1 }
|
|
102
|
+
end
|
|
103
|
+
private_class_method :extent
|
|
104
|
+
|
|
105
|
+
def transfer_data(image, transfer)
|
|
106
|
+
transfer == :f16 ? HalfFloat.pack(image.data) : image.data
|
|
107
|
+
end
|
|
108
|
+
private_class_method :transfer_data
|
|
109
|
+
|
|
110
|
+
def validate_format!(image, format)
|
|
111
|
+
info = FORMAT_INFO[format]
|
|
112
|
+
raise FormatError, "unsupported WebGPU texture format #{format.inspect}" unless info
|
|
113
|
+
return info if image.channels == info.fetch(:channels) && image.dtype == info.fetch(:dtype)
|
|
114
|
+
|
|
115
|
+
expected = "#{info.fetch(:dtype)}/#{info.fetch(:channels)}ch"
|
|
116
|
+
raise FormatError, "#{format} requires #{expected} pixels, got #{image.dtype}/#{image.channels}ch"
|
|
117
|
+
end
|
|
118
|
+
private_class_method :validate_format!
|
|
119
|
+
|
|
120
|
+
def normalize_format(format)
|
|
121
|
+
symbol = format.to_sym
|
|
122
|
+
FORMAT_ALIASES.fetch(symbol, symbol)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :normalize_format
|
|
125
|
+
|
|
126
|
+
def normalize_usage(usage)
|
|
127
|
+
values = usage.is_a?(Array) ? usage.dup : [usage]
|
|
128
|
+
values << :copy_dst unless values.include?(:copy_dst)
|
|
129
|
+
values.freeze
|
|
130
|
+
end
|
|
131
|
+
private_class_method :normalize_usage
|
|
132
|
+
|
|
133
|
+
def validate_image!(image)
|
|
134
|
+
raise FormatError, "expected a Texel::Image" unless image.is_a?(Image)
|
|
135
|
+
end
|
|
136
|
+
private_class_method :validate_image!
|
|
137
|
+
end
|
|
138
|
+
end
|