rbgl 0.1.0 → 1.0.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +3 -0
  3. data/CHANGELOG.md +20 -2
  4. data/README.md +130 -74
  5. data/Rakefile +7 -0
  6. data/bench/renderer_bench.rb +283 -0
  7. data/docs/.nojekyll +1 -0
  8. data/docs/index.html +38 -0
  9. data/examples/array_test.rb +1 -1
  10. data/examples/color_test.rb +1 -1
  11. data/examples/cube_spinning.rb +1 -1
  12. data/examples/gradient.rb +1 -1
  13. data/examples/multi_return_test.rb +1 -1
  14. data/examples/plasma.rb +1 -1
  15. data/examples/sphere_raymarch.rb +1 -1
  16. data/examples/triangle_window.rb +1 -1
  17. data/exe/rbgl +42 -0
  18. data/lib/rbgl/cli/doctor.rb +155 -0
  19. data/lib/rbgl/engine/buffer.rb +150 -40
  20. data/lib/rbgl/engine/clip_space_clipper.rb +163 -0
  21. data/lib/rbgl/engine/context.rb +133 -63
  22. data/lib/rbgl/engine/framebuffer.rb +153 -48
  23. data/lib/rbgl/engine/immutable_color.rb +32 -0
  24. data/lib/rbgl/engine/pipeline.rb +38 -7
  25. data/lib/rbgl/engine/rasterizer/attribute_interpolator.rb +195 -0
  26. data/lib/rbgl/engine/rasterizer/line_renderer.rb +88 -0
  27. data/lib/rbgl/engine/rasterizer/point_renderer.rb +35 -0
  28. data/lib/rbgl/engine/rasterizer/triangle_renderer.rb +136 -0
  29. data/lib/rbgl/engine/rasterizer.rb +65 -171
  30. data/lib/rbgl/engine/shader/base_shader.rb +51 -0
  31. data/lib/rbgl/engine/shader/builtins.rb +257 -0
  32. data/lib/rbgl/engine/shader/dynamic_data.rb +73 -0
  33. data/lib/rbgl/engine/shader.rb +5 -318
  34. data/lib/rbgl/engine/texture.rb +264 -51
  35. data/lib/rbgl/engine.rb +4 -1
  36. data/lib/rbgl/gui/backend.rb +21 -34
  37. data/lib/rbgl/gui/backend_factory.rb +89 -0
  38. data/lib/rbgl/gui/cocoa/backend.rb +59 -46
  39. data/lib/rbgl/gui/cocoa/key_mapper.rb +48 -0
  40. data/lib/rbgl/gui/event.rb +4 -2
  41. data/lib/rbgl/gui/file_backend/bmp_writer.rb +63 -0
  42. data/lib/rbgl/gui/file_backend/frame_writer.rb +35 -0
  43. data/lib/rbgl/gui/file_backend/png_writer.rb +18 -0
  44. data/lib/rbgl/gui/file_backend/ppm_writer.rb +26 -0
  45. data/lib/rbgl/gui/file_backend.rb +50 -55
  46. data/lib/rbgl/gui/wayland/backend.rb +214 -60
  47. data/lib/rbgl/gui/wayland/codec.rb +50 -0
  48. data/lib/rbgl/gui/wayland/connection.rb +188 -231
  49. data/lib/rbgl/gui/wayland/event_dispatcher.rb +165 -0
  50. data/lib/rbgl/gui/wayland/global_binder.rb +46 -0
  51. data/lib/rbgl/gui/wayland/input_mapper.rb +61 -0
  52. data/lib/rbgl/gui/wayland/protocol_objects/arguments.rb +63 -0
  53. data/lib/rbgl/gui/wayland/protocol_objects/display.rb +54 -0
  54. data/lib/rbgl/gui/wayland/protocol_objects/seat.rb +115 -0
  55. data/lib/rbgl/gui/wayland/protocol_objects/shm.rb +157 -0
  56. data/lib/rbgl/gui/wayland/protocol_objects/surface.rb +33 -0
  57. data/lib/rbgl/gui/wayland/protocol_objects/xdg_shell.rb +60 -0
  58. data/lib/rbgl/gui/wayland/protocol_objects.rb +8 -0
  59. data/lib/rbgl/gui/window/event_dispatcher.rb +30 -0
  60. data/lib/rbgl/gui/window/render_loop.rb +111 -0
  61. data/lib/rbgl/gui/window.rb +74 -84
  62. data/lib/rbgl/gui/x11/atom_cache.rb +29 -0
  63. data/lib/rbgl/gui/x11/backend.rb +73 -49
  64. data/lib/rbgl/gui/x11/connection.rb +284 -219
  65. data/lib/rbgl/gui/x11/event_parser.rb +71 -0
  66. data/lib/rbgl/gui/x11/key_mapper.rb +48 -0
  67. data/lib/rbgl/gui/x11/pixel_encoder.rb +164 -0
  68. data/lib/rbgl/gui/x11/request_encoder.rb +155 -0
  69. data/lib/rbgl/gui/x11/setup_parser.rb +138 -0
  70. data/lib/rbgl/gui/x11/transport.rb +81 -0
  71. data/lib/rbgl/gui/x11/x_authority.rb +107 -0
  72. data/lib/rbgl/gui.rb +3 -5
  73. data/lib/rbgl/version.rb +1 -1
  74. metadata +63 -24
  75. data/examples/chemical_heartbeat.rb +0 -166
  76. data/examples/dark_transit.rb +0 -166
  77. data/examples/fractured_orb.rb +0 -428
  78. data/examples/fractured_orb_rb.rb +0 -598
  79. data/examples/hexagonal_flow.rb +0 -333
  80. data/examples/teapot.rb +0 -362
  81. data/examples/teapot_mcu.rb +0 -344
@@ -1,324 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "shader/dynamic_data"
4
+ require_relative "shader/builtins"
5
+ require_relative "shader/base_shader"
6
+
3
7
  module RBGL
4
8
  module Engine
5
- class ShaderIO
6
- def initialize
7
- @data = {}
8
- end
9
-
10
- def method_missing(name, *args)
11
- if name.to_s.end_with?("=")
12
- @data[name.to_s.chomp("=").to_sym] = args.first
13
- else
14
- @data[name]
15
- end
16
- end
17
-
18
- def respond_to_missing?(_name, _include_private = false)
19
- true
20
- end
21
-
22
- def [](key)
23
- @data[key]
24
- end
25
-
26
- def []=(key, value)
27
- @data[key] = value
28
- end
29
-
30
- def to_h
31
- @data.dup
32
- end
33
-
34
- def keys
35
- @data.keys
36
- end
37
- end
38
-
39
- class Uniforms
40
- def initialize(data = {})
41
- @data = data.transform_keys(&:to_sym)
42
- end
43
-
44
- def method_missing(name, *args)
45
- if name.to_s.end_with?("=")
46
- @data[name.to_s.chomp("=").to_sym] = args.first
47
- else
48
- @data[name]
49
- end
50
- end
51
-
52
- def respond_to_missing?(_name, _include_private = false)
53
- true
54
- end
55
-
56
- def [](key)
57
- @data[key.to_sym]
58
- end
59
-
60
- def []=(key, value)
61
- @data[key.to_sym] = value
62
- end
63
-
64
- def merge(other)
65
- Uniforms.new(@data.merge(other.to_h))
66
- end
67
-
68
- def to_h
69
- @data.dup
70
- end
71
- end
72
-
73
- module ShaderBuiltins
74
- def vec2(x, y = nil)
75
- y ||= x
76
- Larb::Vec2.new(x, y)
77
- end
78
-
79
- def vec3(x, y = nil, z = nil)
80
- if y.nil? && z.nil?
81
- Larb::Vec3.new(x, x, x)
82
- elsif z.nil? && y.is_a?(Larb::Vec2)
83
- Larb::Vec3.new(x, y.x, y.y)
84
- else
85
- Larb::Vec3.new(x, y, z)
86
- end
87
- end
88
-
89
- def vec4(x, y = nil, z = nil, w = nil)
90
- case
91
- when y.nil? && z.nil? && w.nil?
92
- Larb::Vec4.new(x, x, x, x)
93
- when x.is_a?(Larb::Vec3) && !y.nil?
94
- Larb::Vec4.new(x.x, x.y, x.z, y)
95
- when x.is_a?(Larb::Vec2) && y.is_a?(Larb::Vec2)
96
- Larb::Vec4.new(x.x, x.y, y.x, y.y)
97
- else
98
- Larb::Vec4.new(x, y, z, w)
99
- end
100
- end
101
-
102
- def dot(a, b)
103
- a.dot(b)
104
- end
105
-
106
- def cross(a, b)
107
- a.cross(b)
108
- end
109
-
110
- def normalize(v)
111
- v.normalize
112
- end
113
-
114
- def length(v)
115
- v.length
116
- end
117
-
118
- def reflect(v, n)
119
- v.reflect(n)
120
- end
121
-
122
- def refract(v, n, eta)
123
- cos_i = -dot(n, v)
124
- sin_t2 = eta * eta * (1.0 - cos_i * cos_i)
125
- return vec3(0) if sin_t2 > 1.0
126
-
127
- cos_t = Math.sqrt(1.0 - sin_t2)
128
- v * eta + n * (eta * cos_i - cos_t)
129
- end
130
-
131
- def mix(a, b, t)
132
- case a
133
- when Numeric then a + (b - a) * t
134
- when Larb::Vec2, Larb::Vec3, Larb::Vec4 then a.lerp(b, t)
135
- when Larb::Color then a.lerp(b, t)
136
- end
137
- end
138
- alias lerp mix
139
-
140
- def clamp(v, min_val, max_val)
141
- case v
142
- when Numeric then v.clamp(min_val, max_val)
143
- when Larb::Vec3
144
- Larb::Vec3.new(
145
- v.x.clamp(min_val, max_val),
146
- v.y.clamp(min_val, max_val),
147
- v.z.clamp(min_val, max_val)
148
- )
149
- when Larb::Color then v.clamp
150
- end
151
- end
152
-
153
- def saturate(v)
154
- clamp(v, 0.0, 1.0)
155
- end
156
-
157
- def smoothstep(edge0, edge1, x)
158
- t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0)
159
- t * t * (3.0 - 2.0 * t)
160
- end
161
-
162
- def step(edge, x)
163
- x < edge ? 0.0 : 1.0
164
- end
165
-
166
- def fract(x)
167
- x - x.floor
168
- end
169
-
170
- def mod(x, y)
171
- x - y * (x / y).floor
172
- end
173
-
174
- def abs(x)
175
- case x
176
- when Numeric then x.abs
177
- when Larb::Vec3 then Larb::Vec3.new(x.x.abs, x.y.abs, x.z.abs)
178
- end
179
- end
180
-
181
- def sign(x)
182
- x <=> 0
183
- end
184
-
185
- def floor(x)
186
- case x
187
- when Numeric then x.floor
188
- when Larb::Vec3 then Larb::Vec3.new(x.x.floor, x.y.floor, x.z.floor)
189
- end
190
- end
191
-
192
- def ceil(x)
193
- case x
194
- when Numeric then x.ceil
195
- when Larb::Vec3 then Larb::Vec3.new(x.x.ceil, x.y.ceil, x.z.ceil)
196
- end
197
- end
198
-
199
- def pow(x, y)
200
- case x
201
- when Numeric then x**y
202
- when Larb::Vec3 then Larb::Vec3.new(x.x**y, x.y**y, x.z**y)
203
- end
204
- end
205
-
206
- def sqrt(x)
207
- case x
208
- when Numeric then Math.sqrt(x)
209
- when Larb::Vec3 then Larb::Vec3.new(Math.sqrt(x.x), Math.sqrt(x.y), Math.sqrt(x.z))
210
- end
211
- end
212
-
213
- def sin(x)
214
- Math.sin(x)
215
- end
216
-
217
- def cos(x)
218
- Math.cos(x)
219
- end
220
-
221
- def tan(x)
222
- Math.tan(x)
223
- end
224
-
225
- def asin(x)
226
- Math.asin(x)
227
- end
228
-
229
- def acos(x)
230
- Math.acos(x)
231
- end
232
-
233
- def atan(y, x = nil)
234
- x ? Math.atan2(y, x) : Math.atan(y)
235
- end
236
-
237
- def min(*args)
238
- args.flatten.min
239
- end
240
-
241
- def max(*args)
242
- args.flatten.max
243
- end
244
-
245
- def texture(tex, uv)
246
- tex.sample(uv.x, uv.y)
247
- end
248
-
249
- def texture_lod(tex, uv, lod)
250
- tex.sample(uv.x, uv.y, lod: lod)
251
- end
252
-
253
- def rgb(r, g, b)
254
- Larb::Color.rgb(r, g, b)
255
- end
256
-
257
- def rgba(r, g, b, a)
258
- Larb::Color.rgba(r, g, b, a)
259
- end
260
-
261
- def color_from_vec3(v)
262
- Larb::Color.from_vec3(v)
263
- end
264
-
265
- def color_from_vec4(v)
266
- Larb::Color.from_vec4(v)
267
- end
268
- end
269
-
270
- class VertexShader
271
- include ShaderBuiltins
272
-
273
- def initialize(&block)
274
- @process_block = block
275
- end
276
-
277
- def process(input, uniforms)
278
- output = ShaderIO.new
279
- @input = input
280
- @uniforms = uniforms
281
- @output = output
282
-
283
- instance_exec(input, uniforms, output, &@process_block)
284
-
285
- raise "VertexShader must set output.position" unless output[:position]
286
-
287
- output
288
- end
289
-
290
- attr_reader :input, :uniforms, :output
291
-
292
- def self.create(&block)
293
- new(&block)
294
- end
295
- end
296
-
297
- class FragmentShader
298
- include ShaderBuiltins
299
-
300
- def initialize(&block)
301
- @process_block = block
302
- end
303
-
304
- def process(input, uniforms)
305
- output = ShaderIO.new
306
- @input = input
307
- @uniforms = uniforms
308
- @output = output
309
-
310
- instance_exec(input, uniforms, output, &@process_block)
311
-
312
- output[:color] ||= Larb::Color.white
313
-
314
- output
315
- end
316
-
317
- attr_reader :input, :uniforms, :output
318
-
319
- def self.create(&block)
320
- new(&block)
321
- end
322
- end
9
+ private_constant :DynamicData
323
10
  end
324
11
  end
@@ -3,8 +3,11 @@
3
3
  module RBGL
4
4
  module Engine
5
5
  class Texture
6
- attr_reader :width, :height, :data
7
- attr_accessor :wrap_s, :wrap_t, :filter_min, :filter_mag
6
+ PPM_HEADER = /\A(P[36])(?:\s+|#[^\n]*\n)+(\d+)(?:\s+|#[^\n]*\n)+(\d+)(?:\s+|#[^\n]*\n)+(\d+)\s/m
7
+ WRAP_MODES = %i[repeat clamp mirror].freeze
8
+ FILTER_MODES = %i[nearest linear].freeze
9
+
10
+ attr_reader :width, :height, :wrap_s, :wrap_t, :filter_min, :filter_mag
8
11
 
9
12
  WRAP_REPEAT = :repeat
10
13
  WRAP_CLAMP = :clamp
@@ -16,7 +19,9 @@ module RBGL
16
19
  def initialize(width, height, data = nil)
17
20
  @width = width
18
21
  @height = height
19
- @data = data || Array.new(width * height) { Larb::Color.black }
22
+ @data = data ? normalize_data!(data) : Array.new(width * height, immutable_color(Larb::Color.black))
23
+ @levels = [@data]
24
+ @mipmaps_dirty = true
20
25
  @wrap_s = WRAP_REPEAT
21
26
  @wrap_t = WRAP_REPEAT
22
27
  @filter_min = FILTER_LINEAR
@@ -24,50 +29,60 @@ module RBGL
24
29
  end
25
30
 
26
31
  def sample(u, v, lod: 0)
27
- u = wrap_coord(u, @wrap_s)
28
- v = wrap_coord(v, @wrap_t)
32
+ level = mip_level_for(lod)
33
+ level_width = width_for_level(level)
34
+ level_height = height_for_level(level)
35
+ filter = filter_for_lod(lod)
36
+ texel_offset = filter == FILTER_LINEAR ? -0.5 : 0.0
29
37
 
30
- x = u * (@width - 1)
31
- y = v * (@height - 1)
38
+ x = (u.to_f * level_width) + texel_offset
39
+ y = (v.to_f * level_height) + texel_offset
32
40
 
33
- if @filter_mag == FILTER_NEAREST
34
- sample_nearest(x, y)
35
- else
36
- sample_bilinear(x, y)
37
- end
41
+ sample_with_filter(level, x, y, filter)
42
+ end
43
+
44
+ def data
45
+ @data.dup.freeze
38
46
  end
39
47
 
40
48
  def get_pixel(x, y)
41
- x = x.clamp(0, @width - 1).to_i
42
- y = y.clamp(0, @height - 1).to_i
43
- @data[y * @width + x]
49
+ pixel_for_level(0, x, y)
44
50
  end
45
51
 
46
52
  def set_pixel(x, y, color)
47
53
  return if x < 0 || x >= @width || y < 0 || y >= @height
48
54
 
49
- @data[y.to_i * @width + x.to_i] = color
55
+ @data[(y.to_i * @width) + x.to_i] = immutable_color(color)
56
+ @mipmaps_dirty = true
57
+ end
58
+
59
+ def generate_mipmaps!
60
+ rebuild_mipmap_chain!
61
+ self
62
+ end
63
+
64
+ def wrap_s=(mode)
65
+ @wrap_s = validate_wrap_mode!(mode)
66
+ end
67
+
68
+ def wrap_t=(mode)
69
+ @wrap_t = validate_wrap_mode!(mode)
70
+ end
71
+
72
+ def filter_min=(filter)
73
+ @filter_min = validate_filter_mode!(filter)
74
+ end
75
+
76
+ def filter_mag=(filter)
77
+ @filter_mag = validate_filter_mode!(filter)
50
78
  end
51
79
 
52
80
  def self.from_ppm(filename)
53
- content = File.read(filename, mode: "rb")
54
- lines = content.lines.reject { |l| l.start_with?("#") }
55
-
56
- _format = lines.shift.strip
57
- dimensions = lines.shift.strip.split.map(&:to_i)
58
- width, height = dimensions
59
- max_val = lines.shift.strip.to_i
60
-
61
- data = []
62
- pixels = lines.join.split.map(&:to_i)
63
- (pixels.size / 3).times do |i|
64
- r = pixels[i * 3] / max_val.to_f
65
- g = pixels[i * 3 + 1] / max_val.to_f
66
- b = pixels[i * 3 + 2] / max_val.to_f
67
- data << Larb::Color.rgb(r, g, b)
68
- end
81
+ content = File.binread(filename)
82
+ format, width, height, max_val, body_offset = parse_ppm_header(content)
83
+ samples = parse_ppm_samples(content, format, width * height, max_val, body_offset)
69
84
 
70
- new(width, height, data)
85
+ new(width, height, colors_from_ppm_samples(samples, max_val))
71
86
  end
72
87
 
73
88
  def self.checker(width, height, size, color1 = Larb::Color.white, color2 = Larb::Color.black)
@@ -75,35 +90,137 @@ module RBGL
75
90
  height.times do |y|
76
91
  width.times do |x|
77
92
  checker = ((x / size) + (y / size)) % 2
78
- data[y * width + x] = checker == 0 ? color1 : color2
93
+ data[(y * width) + x] = checker == 0 ? color1 : color2
79
94
  end
80
95
  end
81
96
  new(width, height, data)
82
97
  end
83
98
 
84
99
  def self.solid(width, height, color)
85
- new(width, height, Array.new(width * height) { color })
100
+ immutable = ImmutableColor.from(color)
101
+ new(width, height, Array.new(width * height, immutable))
102
+ end
103
+
104
+ def self.parse_ppm_header(content)
105
+ match = PPM_HEADER.match(content)
106
+ raise ArgumentError, "Invalid PPM header" unless match
107
+
108
+ format = match[1]
109
+ width = match[2].to_i
110
+ height = match[3].to_i
111
+ max_val = match[4].to_i
112
+ raise ArgumentError, "Unsupported PPM max value: #{max_val}" unless max_val.between?(1, 65_535)
113
+
114
+ [format, width, height, max_val, match.end(0)]
115
+ end
116
+
117
+ def self.parse_ppm_samples(content, format, pixel_count, max_val, body_offset)
118
+ case format
119
+ when "P3"
120
+ parse_p3_samples(content.byteslice(body_offset..), pixel_count, max_val)
121
+ when "P6"
122
+ parse_p6_samples(content, body_offset, pixel_count, max_val)
123
+ else
124
+ raise ArgumentError, "Unsupported PPM format: #{format}"
125
+ end
86
126
  end
87
127
 
128
+ def self.parse_p3_samples(body, pixel_count, max_val)
129
+ tokens = body.to_s.gsub(/#[^\n]*/, " ").split
130
+ expected_count = pixel_count * 3
131
+ raise ArgumentError, "PPM pixel data is truncated" if tokens.length < expected_count
132
+
133
+ tokens.first(expected_count).map do |token|
134
+ raise ArgumentError, "Invalid PPM sample: #{token}" unless /\A\d+\z/.match?(token)
135
+
136
+ value = token.to_i
137
+ raise ArgumentError, "PPM sample exceeds max value: #{value}" if value > max_val
138
+
139
+ value
140
+ end
141
+ end
142
+
143
+ def self.parse_p6_samples(content, body_offset, pixel_count, max_val)
144
+ sample_size = max_val < 256 ? 1 : 2
145
+ expected_bytes = pixel_count * 3 * sample_size
146
+ body = content.byteslice(body_offset, expected_bytes)
147
+ raise ArgumentError, "PPM pixel data is truncated" unless body && body.bytesize == expected_bytes
148
+
149
+ if sample_size == 1
150
+ body.bytes
151
+ else
152
+ body.unpack("n*")
153
+ end
154
+ end
155
+
156
+ def self.colors_from_ppm_samples(samples, max_val)
157
+ samples.each_slice(3).map do |r, g, b|
158
+ Larb::Color.rgb(r / max_val.to_f, g / max_val.to_f, b / max_val.to_f)
159
+ end
160
+ end
161
+
162
+ private_class_method :parse_ppm_header, :parse_ppm_samples, :parse_p3_samples,
163
+ :parse_p6_samples, :colors_from_ppm_samples
164
+
88
165
  private
89
166
 
90
- def wrap_coord(coord, mode)
91
- case mode
92
- when WRAP_REPEAT
93
- coord - coord.floor
94
- when WRAP_CLAMP
95
- coord.clamp(0.0, 1.0)
96
- when WRAP_MIRROR
97
- t = coord - coord.floor
98
- coord.floor.to_i.even? ? t : 1.0 - t
167
+ def normalize_data!(data)
168
+ raise ArgumentError, "Texture data must be convertible to an Array" unless data.respond_to?(:to_a)
169
+
170
+ normalized = data.to_a
171
+ expected_size = @width * @height
172
+
173
+ unless normalized.size == expected_size
174
+ raise ArgumentError, "Texture data size mismatch: expected #{expected_size}, got #{normalized.size}"
99
175
  end
176
+
177
+ normalized.map { |pixel| immutable_color(pixel) }
100
178
  end
101
179
 
102
- def sample_nearest(x, y)
103
- get_pixel(x.round, y.round)
180
+ def validate_color!(color)
181
+ return color if color.is_a?(Larb::Color)
182
+
183
+ raise ArgumentError, "Texture data must contain only Larb::Color values"
104
184
  end
105
185
 
106
- def sample_bilinear(x, y)
186
+ def immutable_color(color)
187
+ ImmutableColor.from(validate_color!(color))
188
+ end
189
+
190
+ def validate_wrap_mode!(mode)
191
+ normalized = mode.to_sym
192
+ return normalized if WRAP_MODES.include?(normalized)
193
+
194
+ raise ArgumentError, "Unsupported wrap mode: #{mode}"
195
+ end
196
+
197
+ def validate_filter_mode!(mode)
198
+ normalized = mode.to_sym
199
+ return normalized if FILTER_MODES.include?(normalized)
200
+
201
+ raise ArgumentError, "Unsupported filter mode: #{mode}"
202
+ end
203
+
204
+ def sample_nearest(level, x, y)
205
+ pixel_for_level(level, x.floor, y.floor, wrap_s: @wrap_s, wrap_t: @wrap_t)
206
+ end
207
+
208
+ def filter_for_lod(lod)
209
+ lod.to_f.positive? ? @filter_min : @filter_mag
210
+ end
211
+
212
+ def sample_with_filter(level, x, y, filter)
213
+ case filter
214
+ when FILTER_NEAREST
215
+ sample_nearest(level, x, y)
216
+ when FILTER_LINEAR
217
+ sample_bilinear(level, x, y)
218
+ else
219
+ raise ArgumentError, "Unsupported filter mode: #{filter}"
220
+ end
221
+ end
222
+
223
+ def sample_bilinear(level, x, y)
107
224
  x0 = x.floor.to_i
108
225
  y0 = y.floor.to_i
109
226
  x1 = x0 + 1
@@ -111,15 +228,111 @@ module RBGL
111
228
  fx = x - x0
112
229
  fy = y - y0
113
230
 
114
- c00 = get_pixel(x0, y0)
115
- c10 = get_pixel(x1, y0)
116
- c01 = get_pixel(x0, y1)
117
- c11 = get_pixel(x1, y1)
231
+ c00 = pixel_for_level(level, x0, y0, wrap_s: @wrap_s, wrap_t: @wrap_t)
232
+ c10 = pixel_for_level(level, x1, y0, wrap_s: @wrap_s, wrap_t: @wrap_t)
233
+ c01 = pixel_for_level(level, x0, y1, wrap_s: @wrap_s, wrap_t: @wrap_t)
234
+ c11 = pixel_for_level(level, x1, y1, wrap_s: @wrap_s, wrap_t: @wrap_t)
118
235
 
119
236
  c0 = c00.lerp(c10, fx)
120
237
  c1 = c01.lerp(c11, fx)
121
238
  c0.lerp(c1, fy)
122
239
  end
240
+
241
+ def mip_level_for(lod)
242
+ return 0 unless lod.to_f.positive?
243
+
244
+ rebuild_mipmap_chain! if @mipmaps_dirty
245
+ [lod.floor, @levels.size - 1].min
246
+ end
247
+
248
+ def pixel_for_level(level, x, y, wrap_s: WRAP_CLAMP, wrap_t: WRAP_CLAMP)
249
+ level_width = width_for_level(level)
250
+ level_height = height_for_level(level)
251
+ level_data = level.zero? ? @data : level_data(level)
252
+ sample_x = wrap_index(x.to_i, level_width, wrap_s)
253
+ sample_y = wrap_index(y.to_i, level_height, wrap_t)
254
+ level_data[(sample_y * level_width) + sample_x]
255
+ end
256
+
257
+ def wrap_index(index, size, mode)
258
+ case mode
259
+ when WRAP_REPEAT
260
+ index % size
261
+ when WRAP_CLAMP
262
+ index.clamp(0, size - 1)
263
+ when WRAP_MIRROR
264
+ mirrored = index % (size * 2)
265
+ mirrored < size ? mirrored : ((size * 2) - mirrored - 1)
266
+ end
267
+ end
268
+
269
+ def level_data(level)
270
+ rebuild_mipmap_chain! if @mipmaps_dirty
271
+ @levels[level] || @data
272
+ end
273
+
274
+ def width_for_level(level)
275
+ [@width >> level, 1].max
276
+ end
277
+
278
+ def height_for_level(level)
279
+ [@height >> level, 1].max
280
+ end
281
+
282
+ def rebuild_mipmap_chain!
283
+ @levels = [@data]
284
+
285
+ current_data = @data
286
+ current_width = @width
287
+ current_height = @height
288
+
289
+ while current_width > 1 || current_height > 1
290
+ next_width = [current_width / 2, 1].max
291
+ next_height = [current_height / 2, 1].max
292
+ current_data = build_next_level(current_data, current_width, current_height, next_width, next_height)
293
+ @levels << current_data
294
+ current_width = next_width
295
+ current_height = next_height
296
+ end
297
+
298
+ @mipmaps_dirty = false
299
+ end
300
+
301
+ def build_next_level(source_data, source_width, source_height, target_width, target_height)
302
+ Array.new(target_width * target_height) do |index|
303
+ x = index % target_width
304
+ y = index / target_width
305
+ start_x = x * source_width.fdiv(target_width)
306
+ end_x = x == target_width - 1 ? source_width.to_f : (x + 1) * source_width.fdiv(target_width)
307
+ start_y = y * source_height.fdiv(target_height)
308
+ end_y = y == target_height - 1 ? source_height.to_f : (y + 1) * source_height.fdiv(target_height)
309
+ average_texel_area(source_data, source_width, start_x, end_x, start_y, end_y)
310
+ end
311
+ end
312
+
313
+ def average_texel_area(source_data, source_width, start_x, end_x, start_y, end_y)
314
+ red = green = blue = alpha = total_weight = 0.0
315
+
316
+ (start_y.floor...end_y.ceil).each do |y|
317
+ y_weight = [end_y, y + 1].min - [start_y, y].max
318
+ (start_x.floor...end_x.ceil).each do |x|
319
+ weight = ([end_x, x + 1].min - [start_x, x].max) * y_weight
320
+ color = source_data[(y * source_width) + x]
321
+ red += color.r * weight
322
+ green += color.g * weight
323
+ blue += color.b * weight
324
+ alpha += color.a * weight
325
+ total_weight += weight
326
+ end
327
+ end
328
+
329
+ immutable_color(Larb::Color.new(
330
+ red / total_weight,
331
+ green / total_weight,
332
+ blue / total_weight,
333
+ alpha / total_weight
334
+ ))
335
+ end
123
336
  end
124
337
  end
125
338
  end