rugl 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.
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rugl
4
+ class CommandCompiler
5
+ STATE_KEYS = %i[
6
+ depth blend stencil scissor cull polygon_offset color_mask
7
+ front_face line_width dither sample viewport
8
+ ].freeze
9
+
10
+ def initialize(context)
11
+ @context = context
12
+ end
13
+
14
+ def compile(opts)
15
+ validate_command_opts!(opts)
16
+
17
+ program = get_or_create_program(opts.fetch(:vert), opts.fetch(:frag))
18
+ attributes = compile_attributes(opts[:attributes] || {}, program)
19
+ uniforms = compile_uniforms(opts[:uniforms] || {}, program)
20
+ elements = opts[:elements]
21
+ state = compile_state(opts)
22
+
23
+ draw_params = {
24
+ count: opts[:count],
25
+ primitive: opts.fetch(:primitive, :triangles),
26
+ offset: opts.fetch(:offset, 0),
27
+ instances: opts[:instances]
28
+ }
29
+
30
+ vao = create_vao(attributes, elements)
31
+
32
+ Command.new(
33
+ context: @context,
34
+ program: program,
35
+ vao: vao,
36
+ attributes: attributes,
37
+ uniforms: uniforms,
38
+ elements: elements,
39
+ state: state,
40
+ draw_params: draw_params,
41
+ framebuffer: opts[:framebuffer]
42
+ )
43
+ end
44
+
45
+ private
46
+
47
+ def validate_command_opts!(opts)
48
+ raise CommandError, "command options must be a Hash" unless opts.is_a?(Hash)
49
+ raise CommandError, "command requires :vert shader source" if opts[:vert].to_s.empty?
50
+ raise CommandError, "command requires :frag shader source" if opts[:frag].to_s.empty?
51
+ end
52
+
53
+ def get_or_create_program(vert_source, frag_source)
54
+ key = [vert_source.hash, frag_source.hash]
55
+ @context.shader_cache[key] ||= Program.new(
56
+ gl: @context.gl,
57
+ vert_source: vert_source,
58
+ frag_source: frag_source,
59
+ debug: @context.debug
60
+ )
61
+ end
62
+
63
+ def compile_attributes(raw_attributes, program)
64
+ raw_attributes.each_with_object({}) do |(name, entry), compiled|
65
+ compiled[name.to_sym] = compile_single_attribute(name.to_sym, entry, program)
66
+ end
67
+ end
68
+
69
+ def compile_single_attribute(name, entry, program)
70
+ location = program.attribute_location(name)
71
+
72
+ case entry
73
+ when Resources::Buffer
74
+ {
75
+ location: location,
76
+ buffer: entry,
77
+ source: entry,
78
+ dynamic: false,
79
+ size: infer_attribute_size(entry),
80
+ type: entry.type,
81
+ stride: 0,
82
+ offset: 0,
83
+ divisor: nil,
84
+ normalized: false
85
+ }
86
+ when Hash
87
+ buffer_source = entry.fetch(:buffer, entry[:value])
88
+ raise CommandError, "Attribute #{name} is missing :buffer" if buffer_source.nil?
89
+
90
+ dynamic = Dynamic.dynamic?(buffer_source)
91
+ {
92
+ location: location,
93
+ buffer: dynamic ? nil : buffer_source,
94
+ source: buffer_source,
95
+ dynamic: dynamic,
96
+ size: entry.fetch(:size, infer_attribute_size(buffer_source)),
97
+ type: entry.fetch(:type, infer_attribute_type(buffer_source)),
98
+ stride: entry.fetch(:stride, 0),
99
+ offset: entry.fetch(:offset, 0),
100
+ divisor: entry[:divisor],
101
+ normalized: entry.fetch(:normalized, false)
102
+ }
103
+ else
104
+ raise CommandError, "Attribute #{name} must be a Buffer or Hash definition"
105
+ end
106
+ end
107
+
108
+ def compile_uniforms(raw_uniforms, program)
109
+ uniform_types = program.uniform_types
110
+
111
+ raw_uniforms.each_with_object({}) do |(name, value), compiled|
112
+ uniform_name = name.to_sym
113
+ type = uniform_types.fetch(uniform_name, infer_uniform_type(value))
114
+ compiled[uniform_name] = {
115
+ value: value,
116
+ type: type,
117
+ location: program.uniform_location(uniform_name)
118
+ }
119
+ end
120
+ end
121
+
122
+ def compile_state(opts)
123
+ STATE_KEYS.each_with_object({}) do |key, state|
124
+ state[key] = opts[key] if opts.key?(key)
125
+ end
126
+ end
127
+
128
+ def create_vao(attributes, elements)
129
+ vao = Resources::Vao.new(@context)
130
+ vao.bind
131
+
132
+ attributes.each_value do |spec|
133
+ next if spec[:dynamic]
134
+
135
+ vao.setup_attribute(
136
+ location: spec[:location],
137
+ buffer: spec[:buffer],
138
+ size: spec[:size],
139
+ type: spec[:type],
140
+ stride: spec[:stride],
141
+ offset: spec[:offset],
142
+ divisor: spec[:divisor],
143
+ normalized: spec[:normalized]
144
+ )
145
+ end
146
+
147
+ elements&.bind
148
+ vao.unbind
149
+ vao
150
+ end
151
+
152
+ def infer_attribute_size(buffer_like)
153
+ return 2 unless buffer_like.respond_to?(:length)
154
+
155
+ length = buffer_like.length.to_i
156
+ return 1 if length <= 1
157
+ return 2 if (length % 2).zero?
158
+ return 3 if (length % 3).zero?
159
+ return 4 if (length % 4).zero?
160
+
161
+ 1
162
+ end
163
+
164
+ def infer_attribute_type(buffer_like)
165
+ return :float unless buffer_like.respond_to?(:type)
166
+
167
+ buffer_like.type
168
+ end
169
+
170
+ def infer_uniform_type(value)
171
+ return :sampler2D if value.is_a?(Resources::Texture)
172
+
173
+ return :int if value.is_a?(Integer)
174
+ return :float if value.is_a?(Numeric)
175
+ return :bool if value == true || value == false
176
+
177
+ if value.is_a?(Array)
178
+ case value.length
179
+ when 2 then :vec2
180
+ when 3 then :vec3
181
+ when 4 then :vec4
182
+ when 9 then :mat3
183
+ when 16 then :mat4
184
+ else
185
+ :float
186
+ end
187
+ else
188
+ :float
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,500 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rugl
4
+ class Context
5
+ class NoopStateManager
6
+ def apply(_command_state, context:, props:)
7
+ {}
8
+ end
9
+
10
+ def restore(_previous_state)
11
+ nil
12
+ end
13
+ end
14
+
15
+ attr_reader :window, :debug, :state_manager, :shader_cache, :tick, :pixel_ratio, :gl, :glfw
16
+
17
+ def initialize(
18
+ width: nil,
19
+ height: nil,
20
+ title: "Rugl",
21
+ pixel_ratio: 1.0,
22
+ debug: true,
23
+ version: [4, 1],
24
+ profile: :core,
25
+ samples: nil,
26
+ window: nil,
27
+ gl: nil,
28
+ glfw: nil,
29
+ state_manager: nil,
30
+ auto_init: true,
31
+ **_opts
32
+ )
33
+ @width = width
34
+ @height = height
35
+ @title = title
36
+ @pixel_ratio = pixel_ratio
37
+ @debug = debug
38
+ @version = version
39
+ @profile = profile
40
+ @samples = samples
41
+ @window = window
42
+ @gl = gl
43
+ @glfw = glfw
44
+ @state_manager = state_manager || default_state_manager
45
+ @resources = []
46
+ @shader_cache = {}
47
+ @scope_stack = []
48
+ @start_time = monotonic_time
49
+ @tick = 0
50
+ @running = false
51
+ @destroyed = false
52
+ @compiler = nil
53
+
54
+ setup_default_window if auto_init
55
+ end
56
+
57
+ def command(opts)
58
+ @compiler ||= CommandCompiler.new(self)
59
+ @compiler.compile(opts)
60
+ end
61
+
62
+ def frame(max_frames: nil, &block)
63
+ raise ArgumentError, "frame requires a block" unless block
64
+ raise ContextError, "Context already destroyed" if destroyed?
65
+
66
+ cancel = -> { @running = false }
67
+ @running = true
68
+ frames = 0
69
+ frame_limit = max_frames || default_frame_limit
70
+
71
+ while @running
72
+ break if frame_limit && frames >= frame_limit
73
+ break if window_should_close?
74
+
75
+ poll_events
76
+ @tick += 1
77
+ block.call(build_context)
78
+ swap_buffers
79
+ frames += 1
80
+ end
81
+
82
+ cancel
83
+ end
84
+
85
+ def clear(color: nil, depth: nil, stencil: nil)
86
+ return unless @gl
87
+
88
+ mask = 0
89
+
90
+ if color
91
+ r, g, b, a = normalize_color(color)
92
+ gl_call(:ClearColor, r, g, b, a)
93
+ mask |= gl_const(:COLOR_BUFFER_BIT)
94
+ end
95
+
96
+ unless depth.nil?
97
+ if gl_respond_to?(:ClearDepth)
98
+ gl_call(:ClearDepth, depth.to_f)
99
+ elsif gl_respond_to?(:ClearDepthf)
100
+ gl_call(:ClearDepthf, depth.to_f)
101
+ end
102
+ mask |= gl_const(:DEPTH_BUFFER_BIT)
103
+ end
104
+
105
+ unless stencil.nil?
106
+ gl_call(:ClearStencil, stencil.to_i) if gl_respond_to?(:ClearStencil)
107
+ mask |= gl_const(:STENCIL_BUFFER_BIT)
108
+ end
109
+
110
+ gl_call(:Clear, mask) if mask.positive? && gl_respond_to?(:Clear)
111
+ Util.check_gl_error!(gl: @gl, debug: @debug, message: "clear")
112
+ nil
113
+ end
114
+
115
+ def read(x: 0, y: 0, width: nil, height: nil)
116
+ read_width, read_height = width_height_or_framebuffer(width, height)
117
+ byte_length = [read_width * read_height * 4, 0].max
118
+ buffer = "\0" * byte_length
119
+ return buffer if @gl.nil?
120
+
121
+ return buffer unless gl_respond_to?(:ReadPixels)
122
+
123
+ gl_call(
124
+ :ReadPixels,
125
+ x.to_i,
126
+ y.to_i,
127
+ read_width,
128
+ read_height,
129
+ gl_const(:RGBA),
130
+ gl_const(:UNSIGNED_BYTE),
131
+ buffer
132
+ )
133
+ buffer
134
+ end
135
+
136
+ def destroy
137
+ return if @destroyed
138
+
139
+ @resources.dup.each do |resource|
140
+ next if !resource.respond_to?(:destroyed?) || resource.destroyed?
141
+
142
+ warn "[Rugl] Resource leak detected: #{resource.class}##{resource.respond_to?(:id) ? resource.id : "unknown"}" # rubocop:disable Style/GlobalStdStream
143
+ resource.destroy if resource.respond_to?(:destroy)
144
+ end
145
+
146
+ if @window && @glfw
147
+ glfw_call(:DestroyWindow, @window) if glfw_respond_to?(:DestroyWindow)
148
+ end
149
+ glfw_call(:Terminate) if @glfw && glfw_respond_to?(:Terminate)
150
+
151
+ @destroyed = true
152
+ @window = nil
153
+ @running = false
154
+ nil
155
+ end
156
+
157
+ def destroyed?
158
+ @destroyed
159
+ end
160
+
161
+ def limits
162
+ {
163
+ max_texture_size: query_integer(:MAX_TEXTURE_SIZE),
164
+ max_viewport_dims: query_integer_array(:MAX_VIEWPORT_DIMS, size: 2),
165
+ max_vertex_attribs: query_integer(:MAX_VERTEX_ATTRIBS),
166
+ version: query_version
167
+ }
168
+ end
169
+
170
+ def has_extension?(name)
171
+ return false if @gl.nil?
172
+
173
+ extension_name = name.to_s
174
+ return false if extension_name.empty?
175
+
176
+ if gl_respond_to?(:GetStringi)
177
+ count = query_integer(:NUM_EXTENSIONS)
178
+ return false if count.nil?
179
+
180
+ count.times do |index|
181
+ ext = gl_call(:GetStringi, gl_const(:EXTENSIONS), index)
182
+ return true if ext.to_s == extension_name
183
+ end
184
+ return false
185
+ end
186
+
187
+ return false unless gl_respond_to?(:GetString)
188
+
189
+ all_extensions = gl_call(:GetString, gl_const(:EXTENSIONS)).to_s.split
190
+ all_extensions.include?(extension_name)
191
+ end
192
+
193
+ def current_context
194
+ build_context
195
+ end
196
+
197
+ def push_scope(state:, props:)
198
+ @scope_stack << { state: state || {}, props: props || {} }
199
+ end
200
+
201
+ def pop_scope
202
+ @scope_stack.pop
203
+ end
204
+
205
+ def current_scope_props
206
+ @scope_stack.each_with_object({}) do |entry, merged|
207
+ merged.merge!(entry[:props])
208
+ end
209
+ end
210
+
211
+ def register_resource(resource)
212
+ @resources << resource
213
+ resource
214
+ end
215
+
216
+ def unregister_resource(resource)
217
+ @resources.delete(resource)
218
+ resource
219
+ end
220
+
221
+ def buffer(source)
222
+ Resources::Buffer.new(self, source)
223
+ end
224
+
225
+ def elements(source)
226
+ Resources::Elements.new(self, source)
227
+ end
228
+
229
+ def texture(source = {})
230
+ Resources::Texture.new(self, source)
231
+ end
232
+
233
+ def renderbuffer(opts)
234
+ Resources::Renderbuffer.new(self, opts)
235
+ end
236
+
237
+ def framebuffer(opts)
238
+ Resources::Framebuffer.new(self, opts)
239
+ end
240
+
241
+ def call_gl(name, *args)
242
+ return nil unless @gl
243
+
244
+ Util.gl_call(@gl, name, *args)
245
+ end
246
+
247
+ def gl_supports?(name)
248
+ return false unless @gl
249
+
250
+ snake_name = Util.underscore(name)
251
+ @gl.respond_to?(name) || @gl.respond_to?(snake_name)
252
+ end
253
+
254
+ def gl_constant(name)
255
+ Util.gl_const(name, gl: @gl)
256
+ end
257
+
258
+ private
259
+
260
+ def default_state_manager
261
+ return NoopStateManager.new unless defined?(StateManager)
262
+
263
+ StateManager.new(gl: @gl)
264
+ end
265
+
266
+ def setup_default_window
267
+ @gl ||= load_gl_backend
268
+ @glfw ||= load_glfw_backend
269
+ return if @window
270
+ return unless @glfw
271
+
272
+ init_glfw
273
+ create_window
274
+ make_context_current
275
+ load_gl_functions
276
+ end
277
+
278
+ def init_glfw
279
+ return unless glfw_respond_to?(:Init)
280
+
281
+ initialized = glfw_call(:Init)
282
+ raise ContextError, "Failed to initialize GLFW" if initialized == false
283
+
284
+ set_window_hints
285
+ end
286
+
287
+ def set_window_hints
288
+ return unless glfw_respond_to?(:WindowHint)
289
+
290
+ major, minor = @version
291
+ hint(:CONTEXT_VERSION_MAJOR, major)
292
+ hint(:CONTEXT_VERSION_MINOR, minor)
293
+ hint(:OPENGL_FORWARD_COMPAT, 1)
294
+ hint(:OPENGL_PROFILE, profile_constant(@profile))
295
+ hint(:SAMPLES, @samples) if @samples
296
+ end
297
+
298
+ def hint(name, value)
299
+ return unless glfw_has_constant?(name)
300
+
301
+ glfw_call(:WindowHint, glfw_const(name), value)
302
+ end
303
+
304
+ def profile_constant(profile)
305
+ case profile&.to_sym
306
+ when :core
307
+ glfw_const(:OPENGL_CORE_PROFILE)
308
+ when :compatibility
309
+ glfw_const(:OPENGL_COMPAT_PROFILE)
310
+ else
311
+ glfw_const(:OPENGL_CORE_PROFILE)
312
+ end
313
+ rescue ArgumentError
314
+ 0
315
+ end
316
+
317
+ def create_window
318
+ return unless glfw_respond_to?(:CreateWindow)
319
+
320
+ width = @width || 800
321
+ height = @height || 600
322
+ @window = glfw_call(:CreateWindow, width, height, @title, nil, nil)
323
+ raise ContextError, "Failed to create GLFW window" if @window.nil?
324
+ end
325
+
326
+ def make_context_current
327
+ return unless @window
328
+ return unless glfw_respond_to?(:MakeContextCurrent)
329
+
330
+ glfw_call(:MakeContextCurrent, @window)
331
+ end
332
+
333
+ def load_gl_functions
334
+ return unless @gl
335
+
336
+ # Supports both `GL.load_lib` and `GL.LoadFunctions` style backends.
337
+ if @gl.respond_to?(:load_lib)
338
+ @gl.load_lib
339
+ elsif @gl.respond_to?(:LoadFunctions)
340
+ @gl.LoadFunctions
341
+ end
342
+ end
343
+
344
+ def default_frame_limit
345
+ return nil if @window
346
+
347
+ 1
348
+ end
349
+
350
+ def poll_events
351
+ return unless @glfw && glfw_respond_to?(:PollEvents)
352
+
353
+ glfw_call(:PollEvents)
354
+ end
355
+
356
+ def swap_buffers
357
+ return unless @window
358
+ return unless @glfw && glfw_respond_to?(:SwapBuffers)
359
+
360
+ glfw_call(:SwapBuffers, @window)
361
+ end
362
+
363
+ def window_should_close?
364
+ return false unless @window
365
+ return false unless @glfw && glfw_respond_to?(:WindowShouldClose)
366
+
367
+ glfw_call(:WindowShouldClose, @window) == true
368
+ end
369
+
370
+ def build_context
371
+ framebuffer_width, framebuffer_height = framebuffer_size
372
+ viewport_width, viewport_height = viewport_size
373
+ {
374
+ tick: @tick,
375
+ time: monotonic_time - @start_time,
376
+ viewport_width: viewport_width,
377
+ viewport_height: viewport_height,
378
+ framebuffer_width: framebuffer_width,
379
+ framebuffer_height: framebuffer_height,
380
+ drawing_buffer_width: framebuffer_width,
381
+ drawing_buffer_height: framebuffer_height,
382
+ pixel_ratio: @pixel_ratio
383
+ }
384
+ end
385
+
386
+ def viewport_size
387
+ return [@width || 0, @height || 0] unless @window && @glfw
388
+ return [@width || 0, @height || 0] unless glfw_respond_to?(:GetWindowSize)
389
+
390
+ glfw_call(:GetWindowSize, @window)
391
+ rescue StandardError
392
+ [@width || 0, @height || 0]
393
+ end
394
+
395
+ def framebuffer_size
396
+ return [@width || 0, @height || 0] unless @window && @glfw
397
+ return [@width || 0, @height || 0] unless glfw_respond_to?(:GetFramebufferSize)
398
+
399
+ glfw_call(:GetFramebufferSize, @window)
400
+ rescue StandardError
401
+ [@width || 0, @height || 0]
402
+ end
403
+
404
+ def width_height_or_framebuffer(width, height)
405
+ fb_width, fb_height = framebuffer_size
406
+ [
407
+ (width || fb_width).to_i,
408
+ (height || fb_height).to_i
409
+ ]
410
+ end
411
+
412
+ def normalize_color(color)
413
+ values = Array(color).map(&:to_f)
414
+ raise ArgumentError, "Color must include at least 4 elements" if values.length < 4
415
+
416
+ values[0, 4]
417
+ end
418
+
419
+ def query_integer(const_name)
420
+ return nil unless @gl
421
+ return nil unless gl_respond_to?(:GetIntegerv)
422
+
423
+ storage = [0]
424
+ gl_call(:GetIntegerv, gl_const(const_name), storage)
425
+ storage.first
426
+ rescue StandardError
427
+ nil
428
+ end
429
+
430
+ def query_integer_array(const_name, size:)
431
+ return nil unless @gl
432
+ return nil unless gl_respond_to?(:GetIntegerv)
433
+
434
+ storage = Array.new(size, 0)
435
+ gl_call(:GetIntegerv, gl_const(const_name), storage)
436
+ storage
437
+ rescue StandardError
438
+ nil
439
+ end
440
+
441
+ def query_version
442
+ return nil unless @gl
443
+ return nil unless gl_respond_to?(:GetString)
444
+
445
+ gl_call(:GetString, gl_const(:VERSION)).to_s
446
+ rescue StandardError
447
+ nil
448
+ end
449
+
450
+ def gl_call(name, *args)
451
+ Util.gl_call(@gl, name, *args)
452
+ end
453
+
454
+ def gl_respond_to?(name)
455
+ snake_name = Util.underscore(name)
456
+ @gl && (@gl.respond_to?(name) || @gl.respond_to?(snake_name))
457
+ end
458
+
459
+ def gl_const(name)
460
+ Util.gl_const(name, gl: @gl)
461
+ end
462
+
463
+ def glfw_call(name, *args)
464
+ Util.gl_call(@glfw, name, *args)
465
+ end
466
+
467
+ def glfw_respond_to?(name)
468
+ snake_name = Util.underscore(name)
469
+ @glfw && (@glfw.respond_to?(name) || @glfw.respond_to?(snake_name))
470
+ end
471
+
472
+ def glfw_const(name)
473
+ raise ArgumentError, "GLFW backend is not available" unless @glfw
474
+
475
+ return @glfw.const_get(name) if @glfw.const_defined?(name)
476
+
477
+ raise ArgumentError, "Unknown GLFW constant #{name}"
478
+ end
479
+
480
+ def glfw_has_constant?(name)
481
+ @glfw&.const_defined?(name)
482
+ end
483
+
484
+ def load_gl_backend
485
+ Backends::OpenGLBindingsGL.load
486
+ rescue LoadError, StandardError
487
+ nil
488
+ end
489
+
490
+ def load_glfw_backend
491
+ Backends::OpenGLBindingsGLFW.load
492
+ rescue LoadError, StandardError
493
+ nil
494
+ end
495
+
496
+ def monotonic_time
497
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
498
+ end
499
+ end
500
+ end