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
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBGL
4
+ module GUI
5
+ class Window
6
+ class RenderLoop
7
+ DEFAULT_TIME_SOURCE = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
8
+ DEFAULT_SLEEPER = ->(duration) { sleep(duration) }
9
+ FPS_WINDOW_SECONDS = 1.0
10
+
11
+ attr_reader :fps, :dropped_frames
12
+
13
+ def initialize(time_source: DEFAULT_TIME_SOURCE, sleeper: DEFAULT_SLEEPER, target_fps: 60)
14
+ @time_source = time_source
15
+ @sleeper = sleeper
16
+ @frame_interval = target_fps && (1.0 / validate_target_fps(target_fps))
17
+ @fps = 0
18
+ @dropped_frames = 0
19
+ @running = false
20
+ @frame_times = []
21
+ end
22
+
23
+ def run(backend:, context:, process_events:, &frame_callback)
24
+ start
25
+
26
+ while @running && !backend.should_close?
27
+ frame_finished_at = step(backend:, context:, process_events:, &frame_callback)
28
+ break unless frame_finished_at
29
+
30
+ throttle(@next_frame_deadline, frame_finished_at)
31
+ @next_frame_deadline = advance_deadline(@next_frame_deadline, frame_finished_at)
32
+ end
33
+ end
34
+
35
+ def start(now: nil)
36
+ @running = true
37
+ @last_time = now.nil? ? @time_source.call : Float(now)
38
+ @next_frame_deadline = @frame_interval && (@last_time + @frame_interval)
39
+ @frame_times.clear
40
+ end
41
+
42
+ def step(backend:, context:, process_events:, now: nil, &frame_callback)
43
+ start(now:) if @last_time.nil?
44
+ return false unless @running
45
+ return false if backend.should_close?
46
+
47
+ frame_started_at = now.nil? ? @time_source.call : Float(now)
48
+ delta_time = frame_started_at - @last_time
49
+ @last_time = frame_started_at
50
+
51
+ process_events.call
52
+ return false if stop_requested?(backend)
53
+ frame_callback&.call(context, delta_time)
54
+ return false if stop_requested?(backend)
55
+
56
+ record_present_result(backend.present(context.framebuffer))
57
+ @last_frame_finished_at = now.nil? ? @time_source.call : frame_started_at
58
+ record_frame(@last_frame_finished_at)
59
+ @last_frame_finished_at
60
+ end
61
+
62
+ def record_present_result(result)
63
+ @dropped_frames += 1 if result == false
64
+ result
65
+ end
66
+
67
+ def stop
68
+ @running = false
69
+ end
70
+
71
+ private
72
+
73
+ def record_frame(timestamp)
74
+ @frame_times << timestamp
75
+ cutoff = timestamp - FPS_WINDOW_SECONDS
76
+ @frame_times.shift while @frame_times.length > 2 && @frame_times.first < cutoff
77
+ return @fps = 0 if @frame_times.length < 2
78
+
79
+ elapsed = @frame_times.last - @frame_times.first
80
+ @fps = elapsed.positive? ? (@frame_times.length - 1) / elapsed : 0
81
+ end
82
+
83
+ def throttle(deadline, frame_finished_at)
84
+ return unless deadline
85
+
86
+ remaining = deadline - frame_finished_at
87
+ @sleeper.call(remaining) if remaining.positive?
88
+ end
89
+
90
+ def advance_deadline(deadline, frame_finished_at)
91
+ return unless deadline
92
+
93
+ [deadline, frame_finished_at].max + @frame_interval
94
+ end
95
+
96
+ def validate_target_fps(target_fps)
97
+ fps = Float(target_fps)
98
+ return fps if fps.positive?
99
+
100
+ raise ArgumentError, "target_fps must be positive or nil"
101
+ rescue ArgumentError, TypeError
102
+ raise ArgumentError, "target_fps must be positive or nil"
103
+ end
104
+
105
+ def stop_requested?(backend)
106
+ !@running || backend.should_close?
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,87 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "window/render_loop"
4
+ require_relative "window/event_dispatcher"
5
+
3
6
  module RBGL
4
7
  module GUI
5
8
  class Window
6
9
  attr_reader :context, :backend, :width, :height
7
10
 
8
- def initialize(width:, height:, title: "RBGL", backend: :auto, **options)
11
+ def initialize(width:, height:, title: "RBGL", backend: :auto, target_fps: 60, **options)
9
12
  @width = width
10
13
  @height = height
11
14
  @title = title
15
+ @render_loop = RenderLoop.new(target_fps: target_fps)
12
16
  @context = Engine::Context.new(width: width, height: height)
13
-
14
- @backend = case backend
15
- when :auto, :native
16
- detect_backend(width, height, title)
17
- when :file
18
- FileBackend.new(width, height, title, **options)
19
- when :x11
20
- require_relative "x11/backend"
21
- X11::Backend.new(width, height, title)
22
- when :wayland
23
- require_relative "wayland/backend"
24
- Wayland::Backend.new(width, height, title)
25
- when :cocoa
26
- require_relative "cocoa/backend"
27
- Cocoa::Backend.new(width, height, title)
28
- when Backend
29
- backend
30
- else
31
- raise "Unknown backend: #{backend}"
32
- end
33
-
34
- @running = false
35
- @frame_callback = nil
36
- @last_time = Time.now
37
- @fps = 0
38
- @frame_count = 0
17
+ @backend = build_backend(backend, width: width, height: height, title: title, **options)
39
18
  @event_handlers = Hash.new { |h, k| h[k] = [] }
19
+ @event_dispatcher = EventDispatcher.new(
20
+ backend: @backend,
21
+ event_handlers: @event_handlers,
22
+ on_resize: method(:apply_resize)
23
+ )
24
+ @closed = false
40
25
  end
41
26
 
42
27
  def on(event_type, &block)
28
+ validate_event_handler!(event_type, block)
43
29
  @event_handlers[event_type] << block
44
30
  end
45
31
 
46
- def on_key(&block)
47
- @backend.on_key(&block)
48
- end
49
-
50
- def on_mouse(&block)
51
- @backend.on_mouse(&block)
52
- end
53
-
54
32
  def run(&frame_callback)
55
- @frame_callback = frame_callback
56
- @running = true
57
- @start_time = Time.now
58
-
59
- while @running && !@backend.should_close?
60
- current_time = Time.now
61
- delta_time = current_time - @last_time
62
- @last_time = current_time
63
-
64
- process_events
65
-
66
- @frame_callback&.call(@context, delta_time)
67
-
68
- @backend.present(@context.framebuffer)
69
-
70
- @frame_count += 1
71
- elapsed = current_time - @start_time
72
- @fps = @frame_count / elapsed if elapsed > 0
33
+ raise RuntimeError, "Window#run is one-shot and cannot be called after the window is closed" if @closed
34
+
35
+ @render_loop.run(
36
+ backend: @backend,
37
+ context: @context,
38
+ process_events: method(:process_events),
39
+ &frame_callback
40
+ )
41
+ ensure
42
+ original_error = $!
43
+ begin
44
+ close if @backend
45
+ rescue StandardError
46
+ raise unless original_error
73
47
  end
48
+ end
74
49
 
75
- @backend.close
50
+ # Advances one frame. `now` is seconds on a monotonic timeline.
51
+ def step(now = nil, &frame_callback)
52
+ return false if @closed
53
+
54
+ !!@render_loop.step(
55
+ backend: @backend,
56
+ context: @context,
57
+ process_events: method(:process_events),
58
+ now:,
59
+ &frame_callback
60
+ )
76
61
  end
77
62
 
78
63
  def stop
79
- @running = false
64
+ @render_loop.stop
80
65
  end
81
66
 
82
67
  def present_framebuffer(framebuffer = nil)
83
68
  fb = framebuffer || @context.framebuffer
84
- @backend.present(fb)
69
+ @render_loop.record_present_result(@backend.present(fb))
85
70
  end
86
71
 
87
72
  def set_pixels(buffer)
@@ -89,11 +74,11 @@ module RBGL
89
74
  end
90
75
 
91
76
  def metal_available?
92
- @backend.metal_available?
77
+ @backend.respond_to?(:metal_available?) && @backend.metal_available?
93
78
  end
94
79
 
95
80
  def native_handle
96
- @backend.native_handle
81
+ @backend.native_handle if @backend.respond_to?(:native_handle)
97
82
  end
98
83
 
99
84
  def should_close?
@@ -101,47 +86,52 @@ module RBGL
101
86
  end
102
87
 
103
88
  def poll_events_raw
104
- @backend.poll_events_raw
89
+ events = @backend.poll_events_raw
90
+ Array(events).each do |event|
91
+ next unless event.respond_to?(:[]) && event[:type] == :resize
92
+
93
+ apply_resize(Event.new(:resize, width: event[:width], height: event[:height]))
94
+ end
95
+ events
105
96
  end
106
97
 
107
98
  def close
99
+ return if @closed
100
+
101
+ @render_loop.stop
108
102
  @backend.close
103
+ @closed = true
109
104
  end
110
105
 
111
- attr_reader :fps
106
+ def fps
107
+ @render_loop.fps
108
+ end
109
+
110
+ def dropped_frames
111
+ @render_loop.dropped_frames
112
+ end
112
113
 
113
114
  private
114
115
 
115
- def detect_backend(width, height, title)
116
- case RUBY_PLATFORM
117
- when /darwin/
118
- require_relative "cocoa/backend"
119
- Cocoa::Backend.new(width, height, title)
120
- when /linux/
121
- if ENV["WAYLAND_DISPLAY"]
122
- require_relative "wayland/backend"
123
- Wayland::Backend.new(width, height, title)
124
- elsif ENV["DISPLAY"]
125
- require_relative "x11/backend"
126
- X11::Backend.new(width, height, title)
127
- else
128
- raise "No display server found (DISPLAY or WAYLAND_DISPLAY not set)"
129
- end
130
- else
131
- raise "Unsupported platform: #{RUBY_PLATFORM}"
132
- end
116
+ def build_backend(backend, width:, height:, title:, **options)
117
+ BackendFactory.build(backend, width: width, height: height, title: title, **options)
133
118
  end
134
119
 
135
120
  def process_events
136
- events = @backend.poll_events
121
+ @event_dispatcher.process
122
+ end
137
123
 
138
- return unless events.is_a?(Array)
124
+ def apply_resize(event)
125
+ @backend.resize(event.width, event.height)
126
+ @context.resize(width: event.width, height: event.height)
127
+ @width = event.width
128
+ @height = event.height
129
+ end
139
130
 
140
- events.each do |event|
141
- next unless event.is_a?(Event)
131
+ def validate_event_handler!(event_type, block)
132
+ return if block
142
133
 
143
- @event_handlers[event.type].each { |handler| handler.call(event) }
144
- end
134
+ raise ArgumentError, "A block is required for #{event_type} handlers"
145
135
  end
146
136
  end
147
137
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RBGL
4
+ module GUI
5
+ module X11
6
+ class AtomCache
7
+ BUILTIN_ATOMS = {
8
+ wm_name: 39,
9
+ string: 31,
10
+ atom: 4
11
+ }.freeze
12
+
13
+ def initialize(&resolver)
14
+ @resolver = resolver
15
+ @cache = BUILTIN_ATOMS.dup
16
+ end
17
+
18
+ def fetch(name)
19
+ return name if name.is_a?(Integer)
20
+ return @cache[name] if @cache.key?(name)
21
+
22
+ resolved = @resolver.call(name)
23
+ @cache[name] = resolved
24
+ resolved
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "connection"
4
+ require_relative "pixel_encoder"
4
5
 
5
6
  module RBGL
6
7
  module GUI
7
8
  module X11
8
9
  class Backend < GUI::Backend
9
- def initialize(width, height, title = "RBGL")
10
- super
11
- @display = Connection.new(ENV["DISPLAY"] || ":0")
12
- @windows = {}
10
+ def initialize(width, height, title = "RBGL", env: ENV, reply_timeout: Connection::DEFAULT_REPLY_TIMEOUT)
11
+ super(width, height, title)
12
+ @display = Connection.new(env["DISPLAY"] || ":0", env: env, reply_timeout: reply_timeout)
13
+ @window = nil
13
14
  setup_window(width, height, title)
15
+ rescue StandardError => error
16
+ begin
17
+ close
18
+ rescue StandardError
19
+ nil
20
+ end
21
+ raise error
14
22
  end
15
23
 
16
24
  private def setup_window(w, h, t)
@@ -35,42 +43,54 @@ module RBGL
35
43
  )
36
44
 
37
45
  @display.change_property(wid, :wm_name, :string, t)
46
+ @display.enable_wm_delete_window(wid)
38
47
  @display.map_window(wid)
39
48
  @display.flush
40
49
 
41
50
  gc_id = @display.generate_id
42
51
  @display.create_gc(gc_id, wid)
43
52
 
44
- @windows[wid] = {
53
+ @window = {
54
+ id: wid,
45
55
  width: w,
46
56
  height: h,
47
57
  gc: gc_id,
48
58
  should_close: false
49
59
  }
50
-
51
- @handle = wid
52
60
  end
53
61
 
54
62
  def present(framebuffer)
55
- return unless @handle
56
-
57
- window = @windows[@handle]
58
- return unless window
63
+ return false unless @window
59
64
 
60
65
  buffer = convert_to_x11_format(framebuffer)
66
+ present_bytes(buffer, framebuffer.width, framebuffer.height)
67
+ end
68
+
69
+ def set_pixels(buffer, width, height)
70
+ return false unless @window
71
+
72
+ bytes = validate_rgba_buffer(buffer, width, height)
73
+ present_bytes(pixel_encoder.encode_rgba(bytes, width, height), width, height)
74
+ end
75
+
76
+ private def present_bytes(buffer, width, height)
77
+ window = @window
78
+ return false unless window
61
79
 
62
80
  @display.put_image(
63
81
  format: :z_pixmap,
64
- drawable: @handle,
82
+ drawable: window[:id],
65
83
  gc: window[:gc],
66
- width: framebuffer.width,
67
- height: framebuffer.height,
84
+ width: width,
85
+ height: height,
68
86
  dst_x: 0, dst_y: 0,
69
87
  depth: @display.root_depth,
70
- data: buffer
88
+ data: buffer,
89
+ bytes_per_line: pixel_encoder.bytes_per_line(width)
71
90
  )
72
91
 
73
92
  @display.flush
93
+ true
74
94
  end
75
95
 
76
96
  def poll_events
@@ -81,42 +101,60 @@ module RBGL
81
101
  next unless raw_event
82
102
 
83
103
  event = convert_event(raw_event)
84
- if event
85
- events << event
86
- emit_from_event(event)
87
- end
104
+ events << event if event
88
105
  end
89
106
 
90
107
  events
91
108
  end
92
109
 
93
110
  def should_close?
94
- return false unless @handle
95
-
96
- @windows[@handle]&.[](:should_close) || false
111
+ @window.nil? || @window[:should_close]
97
112
  end
98
113
 
99
114
  def close
100
- return unless @handle
115
+ window = @window
116
+ operations = []
117
+ if window
118
+ window[:should_close] = true
119
+ operations << -> { @display.destroy_window(window[:id]) }
120
+ operations << -> { @display.flush }
121
+ end
122
+ operations << -> { @display&.close }
101
123
 
102
- @windows[@handle][:should_close] = true
103
- @display.destroy_window(@handle)
104
- @windows.delete(@handle)
124
+ error = nil
125
+ operations.each do |operation|
126
+ operation.call
127
+ rescue StandardError => cleanup_error
128
+ error ||= cleanup_error
129
+ end
130
+ @window = nil
131
+ raise error if error
105
132
  end
106
133
 
107
134
  private
108
135
 
109
136
  def convert_to_x11_format(framebuffer)
110
- # X11 uses BGRX format (blue, green, red, padding)
111
- framebuffer.to_bgra_bytes
137
+ pixel_encoder.encode(framebuffer)
138
+ end
139
+
140
+ def pixel_encoder
141
+ @pixel_encoder ||= PixelEncoder.new(
142
+ bits_per_pixel: @display.bits_per_pixel,
143
+ scanline_pad: @display.scanline_pad,
144
+ visual_class: @display.visual_class,
145
+ red_mask: @display.red_mask,
146
+ green_mask: @display.green_mask,
147
+ blue_mask: @display.blue_mask,
148
+ image_byte_order: @display.image_byte_order
149
+ )
112
150
  end
113
151
 
114
152
  def convert_event(raw)
115
153
  case raw[:type]
116
154
  when :key_press
117
- Event.new(:key_press, key: raw[:keycode])
155
+ Event.new(:key_press, key: @display.key_for_keycode(raw[:keycode]), keycode: raw[:keycode])
118
156
  when :key_release
119
- Event.new(:key_release, key: raw[:keycode])
157
+ Event.new(:key_release, key: @display.key_for_keycode(raw[:keycode]), keycode: raw[:keycode])
120
158
  when :button_press
121
159
  Event.new(:mouse_press, x: raw[:x], y: raw[:y], button: raw[:button])
122
160
  when :button_release
@@ -126,30 +164,16 @@ module RBGL
126
164
  when :configure_notify
127
165
  Event.new(:resize, width: raw[:width], height: raw[:height])
128
166
  when :client_message
129
- if @handle && @windows[@handle]
130
- @windows[@handle][:should_close] = true
131
- end
167
+ return nil unless @window && raw[:window] == @window[:id]
168
+ return nil unless raw[:message_type] == @display.wm_protocols_atom
169
+ return nil unless raw[:data32]&.first == @display.wm_delete_window_atom
170
+
171
+ @window[:should_close] = true
132
172
  Event.new(:close)
133
173
  else
134
174
  nil
135
175
  end
136
176
  end
137
-
138
- def emit_from_event(event)
139
- case event.type
140
- when :key_press, :key_release
141
- emit_key(event.key, event.type == :key_press ? :press : :release)
142
- when :mouse_press, :mouse_release, :mouse_move
143
- action = case event.type
144
- when :mouse_press then :press
145
- when :mouse_release then :release
146
- else :move
147
- end
148
- emit_mouse(event.x, event.y, event[:button], action)
149
- when :resize
150
- emit_resize(event.width, event.height)
151
- end
152
- end
153
177
  end
154
178
  end
155
179
  end