renderdoc 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aab8b86504640671b46abb7746f8fd15a8663b70dc4bcc51e5c5ffa113f7a3ae
4
+ data.tar.gz: e86076c7a2d76ffefea4c798f2c97ddd4d6e548ec6a9f2639672cba44b869afc
5
+ SHA512:
6
+ metadata.gz: 7b5fc719ae37e729bb4c2a0380468c6a091b893ab1304cebc95e8c33b73f42292259ff1b2391b1ef6e76d9cac7b0582e568ba4132839830657c8da6c10916d6c
7
+ data.tar.gz: 1e316d373258f1d24542d90eeecd206e43eac527f6077e1003c17f966770867c2bce902f81ab1edfc95e91b07cd5ddedca840fbc29fe0f927d049f99d022aad7
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # RenderDoc for Ruby
2
+
3
+ [![Ruby](https://github.com/ydah/renderdoc/actions/workflows/main.yml/badge.svg)](https://github.com/ydah/renderdoc/actions/workflows/main.yml)
4
+
5
+ `renderdoc` provides Ruby bindings for [RenderDoc](https://renderdoc.org/)'s in-application API. Use it to capture a specific block of GPU work, schedule frame captures, annotate captures, and open the replay UI from a Ruby application.
6
+
7
+ The gem binds to RenderDoc but does not bundle, load, or inject RenderDoc itself.
8
+
9
+ ## Requirements
10
+
11
+ - Ruby 3.2 or newer
12
+ - RenderDoc with in-application API 1.6.0 or newer
13
+ - Linux or Windows
14
+
15
+ The only runtime gem dependency is [`ffi`](https://github.com/ffi/ffi), which Bundler installs automatically. RenderDoc does not officially support macOS, so `RenderDoc.available?` always returns `false` there. Use Xcode GPU Frame Capture for Metal applications.
16
+
17
+ ## Installation
18
+
19
+ Add the gem to your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem "renderdoc"
23
+ ```
24
+
25
+ Until the first RubyGems release, install it directly from GitHub:
26
+
27
+ ```ruby
28
+ gem "renderdoc", github: "ydah/renderdoc"
29
+ ```
30
+
31
+ Then run:
32
+
33
+ ```sh
34
+ bundle install
35
+ ```
36
+
37
+ ## Loading RenderDoc
38
+
39
+ `ffi` lets Ruby call native functions; it does not place `librenderdoc.so` or `renderdoc.dll` in the process. The RenderDoc library must already be loaded before this gem can use its API.
40
+
41
+ | Platform | How to load RenderDoc |
42
+ |---|---|
43
+ | Linux | Launch the application from RenderDoc, or use `LD_PRELOAD` |
44
+ | Windows | Launch the application from RenderDoc so `renderdoc.dll` is injected |
45
+ | macOS | Unsupported by RenderDoc |
46
+
47
+ For a direct Linux launch:
48
+
49
+ ```sh
50
+ LD_PRELOAD=librenderdoc.so bundle exec ruby app.rb
51
+ ```
52
+
53
+ Use an absolute library path if `librenderdoc.so` is not on the dynamic loader's search path:
54
+
55
+ ```sh
56
+ LD_PRELOAD=/path/to/librenderdoc.so bundle exec ruby app.rb
57
+ ```
58
+
59
+ When the application is launched from the RenderDoc UI, do not also set `LD_PRELOAD`. Requiring this gem without RenderDoc attached is safe: `RenderDoc.available?` returns `false`.
60
+
61
+ ## Quick start
62
+
63
+ ```ruby
64
+ require "renderdoc"
65
+
66
+ if RenderDoc.available?
67
+ RenderDoc.capture_path_template = "captures/my_app"
68
+
69
+ RenderDoc.capture(title: "Bloom pass") do
70
+ renderer.render(scene, camera)
71
+ end
72
+ end
73
+ ```
74
+
75
+ `RenderDoc.capture` returns the block's result. If the block raises an exception, the gem discards the capture and re-raises the original exception. Nested and overlapping captures raise `RenderDoc::CaptureError`.
76
+
77
+ ## Capturing frames
78
+
79
+ ### Capture a block
80
+
81
+ By default, RenderDoc uses wildcard device and window pointers. Supply native pointers when a particular device or window must be selected:
82
+
83
+ ```ruby
84
+ RenderDoc.capture(device: device_pointer, window: window_pointer) do
85
+ renderer.render(scene, camera)
86
+ end
87
+ ```
88
+
89
+ For Vulkan, `device` must be the dispatch-table pointer described by `RENDERDOC_DEVICEPOINTER_FROM_VKINSTANCE`, not the `VkInstance` handle itself.
90
+
91
+ ### Trigger upcoming frames
92
+
93
+ Schedule one or more captures without wrapping render code:
94
+
95
+ ```ruby
96
+ RenderDoc.trigger_capture
97
+ RenderDoc.trigger_capture(frames: 3)
98
+ ```
99
+
100
+ ## Captures and replay UI
101
+
102
+ ```ruby
103
+ RenderDoc.api_version
104
+ # => [1, 6, 0] or a newer compatible version
105
+
106
+ RenderDoc.captures.each do |capture|
107
+ puts "#{capture.path} at #{capture.timestamp.iso8601}"
108
+ end
109
+
110
+ RenderDoc.comments = "scene=atrium commit=abc123"
111
+ RenderDoc.set_capture_comments("regression case", path: "/tmp/frame.rdc")
112
+
113
+ pid = RenderDoc.launch_replay_ui(connect: true)
114
+ RenderDoc.show_replay_ui if RenderDoc.target_control_connected?
115
+ ```
116
+
117
+ `Capture#timestamp` is a UTC `Time`. Paths are UTF-8 strings reported by RenderDoc; a listed capture may have been deleted after it was recorded.
118
+
119
+ ## Options, overlay, and keys
120
+
121
+ ```ruby
122
+ RenderDoc.overlay = false
123
+ RenderDoc.capture_keys = [:f12]
124
+ RenderDoc.focus_toggle_keys = [:f11]
125
+
126
+ RenderDoc.option(:api_validation, true)
127
+ RenderDoc.option(:delay_for_debugger, 3)
128
+ RenderDoc.option_value(:api_validation)
129
+ ```
130
+
131
+ Option names from the API 1.6.0 header are accepted in snake case. Input buttons include function keys, letters, digits, navigation keys, `:print_screen`, and `:pause`. Unknown names and invalid values raise `ArgumentError` before entering FFI.
132
+
133
+ ## Running without RenderDoc
134
+
135
+ By default, API calls other than `available?` raise `RenderDoc::NotAttachedError` when RenderDoc is absent. Applications where capture support is entirely optional can enable no-op behavior:
136
+
137
+ ```ruby
138
+ RenderDoc.configure(unavailable: :noop)
139
+
140
+ RenderDoc.capture { renderer.render(scene, camera) } # still renders
141
+ RenderDoc.trigger_capture # => false
142
+ RenderDoc.captures # => []
143
+ ```
144
+
145
+ | Error | Meaning |
146
+ |---|---|
147
+ | `RenderDoc::NotAttachedError` | RenderDoc is not loaded in the process |
148
+ | `RenderDoc::VersionError` | The loaded RenderDoc cannot provide API 1.6.0 |
149
+ | `RenderDoc::CaptureError` | A capture failed or another capture is in progress |
150
+
151
+ ## Frame-loop integration
152
+
153
+ `RenderDoc::FrameTrigger` parses `STAGECRAFT_RENDERDOC=frame:N` and triggers exactly once. A frame loop such as stagecraft's can soft-require the gem and continue working when it is not installed:
154
+
155
+ ```ruby
156
+ begin
157
+ require "renderdoc"
158
+ renderdoc_trigger = RenderDoc::FrameTrigger.from_environment
159
+ rescue LoadError
160
+ renderdoc_trigger = nil
161
+ end
162
+
163
+ frame_number = 0
164
+ loop do
165
+ renderdoc_trigger&.tick(frame_number)
166
+ renderer.render
167
+ frame_number += 1
168
+ end
169
+ ```
170
+
171
+ For example, set `STAGECRAFT_RENDERDOC=frame:120` before launching the application through RenderDoc to capture frame 120.
172
+
173
+ ## RSpec failure captures
174
+
175
+ Require the optional integration from `spec_helper.rb`:
176
+
177
+ ```ruby
178
+ require "renderdoc/rspec"
179
+ ```
180
+
181
+ Tag examples whose render work can safely run a second time:
182
+
183
+ ```ruby
184
+ it "renders the bloom pass", :renderdoc_on_failure do
185
+ render_and_compare
186
+ end
187
+ ```
188
+
189
+ When a tagged example fails and RenderDoc is attached, the integration runs the example body once more inside `RenderDoc.capture`, preserves the original failure, and reports the resulting `.rdc` path. Do not use the tag for examples with non-repeatable external side effects.
190
+
191
+ ## Development
192
+
193
+ Run the unit suite:
194
+
195
+ ```sh
196
+ bundle exec rake
197
+ ```
198
+
199
+ The Linux integration spec is tagged `:renderdoc` and excluded from the default suite. CI runs it under `renderdoccmd`, Xvfb, SDL2, and Mesa software OpenGL, then verifies the generated `.rdc` file through the in-application API.
200
+
201
+ The ABI constants in `lib/renderdoc/native/generated.rb` are generated from RenderDoc v1.25's `renderdoc_app.h`. After reviewing an upstream header change, regenerate them with:
202
+
203
+ ```sh
204
+ ruby script/generate_native_api.rb path/to/renderdoc_app.h lib/renderdoc/native/generated.rb
205
+ ```
206
+
207
+ ## License
208
+
209
+ The gem is available under the [MIT License](LICENSE.txt). RenderDoc is a separate project and is not distributed with this gem.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+ require "time"
5
+
6
+ require_relative "capture"
7
+ require_relative "configuration"
8
+ require_relative "native"
9
+ require_relative "settings"
10
+
11
+ module RenderDoc
12
+ class Application
13
+ include Settings
14
+
15
+ UINT32_MAX = (2**32) - 1
16
+ NULL_POINTER = FFI::Pointer::NULL
17
+
18
+ def initialize(loader: Native::Loader.new, configuration: Configuration.new)
19
+ @loader = loader
20
+ @configuration = configuration
21
+ @api_mutex = Mutex.new
22
+ @capture_mutex = Mutex.new
23
+ @capturing = false
24
+ end
25
+
26
+ def available?
27
+ !load_api.nil?
28
+ rescue VersionError
29
+ false
30
+ end
31
+
32
+ def api_version
33
+ with_api do |api|
34
+ version = Array.new(3) { FFI::MemoryPointer.new(:int) }
35
+ api.call(:GetAPIVersion, *version)
36
+ version.map(&:read_int)
37
+ end
38
+ end
39
+
40
+ def capture(device: nil, window: nil, title: nil)
41
+ raise ArgumentError, "capture requires a block" unless block_given?
42
+
43
+ api = api_or_nil
44
+ return yield unless api
45
+
46
+ begin_capture(api)
47
+ begin
48
+ api.call(:StartFrameCapture, device, window)
49
+ api.call(:SetCaptureTitle, String(title)) if title
50
+ result = yield
51
+ rescue Exception # rubocop:disable Lint/RescueException -- native capture must always be discarded
52
+ discard_capture(api, device, window)
53
+ raise
54
+ else
55
+ ended = api.call(:EndFrameCapture, device, window)
56
+ raise CaptureError, "RenderDoc failed to save the frame capture" if ended.zero?
57
+
58
+ result
59
+ ensure
60
+ finish_capture
61
+ end
62
+ end
63
+
64
+ def trigger_capture(frames: 1)
65
+ unless frames.is_a?(Integer) && frames.positive? && frames <= UINT32_MAX
66
+ raise ArgumentError, "frames must be an integer between 1 and #{UINT32_MAX}"
67
+ end
68
+
69
+ with_api(default: false) do |api|
70
+ function = frames == 1 ? :TriggerCapture : :TriggerMultiFrameCapture
71
+ arguments = frames == 1 ? [] : [frames]
72
+ api.call(function, *arguments)
73
+ true
74
+ end
75
+ end
76
+
77
+ def capture_path_template
78
+ with_api do |api|
79
+ pointer = api.call(:GetCaptureFilePathTemplate)
80
+ pointer.null? ? nil : pointer.read_string
81
+ end
82
+ end
83
+
84
+ def capture_path_template=(path)
85
+ with_api(default: false) do |api|
86
+ api.call(:SetCaptureFilePathTemplate, String(path))
87
+ true
88
+ end
89
+ end
90
+
91
+ def captures
92
+ with_api(default: []) do |api|
93
+ api.call(:GetNumCaptures).times.map { |index| read_capture(api, index) }
94
+ end
95
+ end
96
+
97
+ def launch_replay_ui(connect: true, command_line: nil)
98
+ with_api(default: 0) do |api|
99
+ api.call(:LaunchReplayUI, connect ? 1 : 0, command_line&.to_s)
100
+ end
101
+ end
102
+
103
+ def target_control_connected?
104
+ with_api(default: false) { |api| !api.call(:IsTargetControlConnected).zero? }
105
+ end
106
+
107
+ def show_replay_ui
108
+ with_api(default: false) { |api| !api.call(:ShowReplayUI).zero? }
109
+ end
110
+
111
+ def comments=(comments)
112
+ with_api(default: false) do |api|
113
+ api.call(:SetCaptureFileComments, nil, String(comments))
114
+ true
115
+ end
116
+ end
117
+
118
+ def set_capture_comments(comments, path: nil)
119
+ with_api(default: false) do |api|
120
+ api.call(:SetCaptureFileComments, path&.to_s, String(comments))
121
+ true
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def load_api
128
+ return @api if @api
129
+ raise @version_error if @version_error
130
+
131
+ @api_mutex.synchronize do
132
+ return @api if @api
133
+ raise @version_error if @version_error
134
+
135
+ @api = @loader.load
136
+ rescue VersionError => error
137
+ @version_error = error
138
+ raise
139
+ end
140
+ end
141
+
142
+ def api_or_nil
143
+ api = load_api
144
+ return api if api
145
+ return if @configuration.noop_when_unavailable?
146
+
147
+ raise NotAttachedError
148
+ end
149
+
150
+ def with_api(default: nil)
151
+ api = api_or_nil
152
+ return default unless api
153
+
154
+ yield api
155
+ end
156
+
157
+ def begin_capture(api)
158
+ @capture_mutex.synchronize do
159
+ if @capturing || !api.call(:IsFrameCapturing).zero?
160
+ raise CaptureError, "a RenderDoc frame capture is already in progress"
161
+ end
162
+
163
+ @capturing = true
164
+ end
165
+ end
166
+
167
+ def finish_capture
168
+ @capture_mutex.synchronize { @capturing = false }
169
+ end
170
+
171
+ def discard_capture(api, device, window)
172
+ api.call(:DiscardFrameCapture, device, window)
173
+ rescue StandardError
174
+ nil
175
+ end
176
+
177
+ def read_capture(api, index)
178
+ path_length = FFI::MemoryPointer.new(:uint32)
179
+ timestamp = FFI::MemoryPointer.new(:uint64)
180
+ found = api.call(:GetCapture, index, NULL_POINTER, path_length, timestamp)
181
+ raise CaptureError, "RenderDoc capture #{index} disappeared while enumerating" if found.zero?
182
+
183
+ buffer = FFI::MemoryPointer.new(:char, [path_length.read_uint32 + 1, 2].max)
184
+ path_length.write_uint32(buffer.size)
185
+ found = api.call(:GetCapture, index, buffer, path_length, timestamp)
186
+ raise CaptureError, "RenderDoc capture #{index} disappeared while reading its path" if found.zero?
187
+
188
+ Capture.new(path: buffer.read_string, timestamp: Time.at(timestamp.read_uint64).utc)
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ Capture = Data.define(:path, :timestamp)
5
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ class Configuration
5
+ UNAVAILABLE_BEHAVIORS = %i[raise noop].freeze
6
+
7
+ attr_reader :unavailable_behavior
8
+
9
+ def initialize
10
+ @unavailable_behavior = :raise
11
+ end
12
+
13
+ def unavailable_behavior=(behavior)
14
+ behavior = behavior.to_sym
15
+ unless UNAVAILABLE_BEHAVIORS.include?(behavior)
16
+ raise ArgumentError, "unavailable behavior must be :raise or :noop"
17
+ end
18
+
19
+ @unavailable_behavior = behavior
20
+ end
21
+
22
+ def noop_when_unavailable?
23
+ unavailable_behavior == :noop
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ class Error < StandardError; end
5
+
6
+ class NotAttachedError < Error
7
+ def initialize(message = nil)
8
+ super(message || self.class.default_message)
9
+ end
10
+
11
+ def self.default_message
12
+ return "RenderDoc does not support macOS. Use Xcode GPU Frame Capture for Metal applications." if RUBY_PLATFORM.include?("darwin")
13
+ return "Launch the application from the RenderDoc UI so renderdoc.dll is already loaded." if Gem.win_platform?
14
+
15
+ "Launch via the RenderDoc UI, or run with LD_PRELOAD=librenderdoc.so ruby app.rb."
16
+ end
17
+ end
18
+
19
+ class VersionError < Error; end
20
+ class CaptureError < Error; end
21
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ class FrameTrigger
5
+ ENVIRONMENT_VARIABLE = "STAGECRAFT_RENDERDOC"
6
+ FRAME_PATTERN = /\Aframe:(\d+)\z/
7
+
8
+ attr_reader :frame
9
+
10
+ def self.from_environment(environment: ENV, renderdoc: RenderDoc)
11
+ specification = environment[ENVIRONMENT_VARIABLE]
12
+ return unless specification
13
+
14
+ match = FRAME_PATTERN.match(specification)
15
+ unless match
16
+ raise ArgumentError, "#{ENVIRONMENT_VARIABLE} must use the form frame:N"
17
+ end
18
+
19
+ new(frame: Integer(match[1], 10), renderdoc: renderdoc)
20
+ end
21
+
22
+ def initialize(frame:, renderdoc: RenderDoc)
23
+ raise ArgumentError, "frame must be a non-negative Integer" unless frame.is_a?(Integer) && frame >= 0
24
+
25
+ @frame = frame
26
+ @renderdoc = renderdoc
27
+ @triggered = false
28
+ end
29
+
30
+ def call(current_frame)
31
+ return false if @triggered || current_frame != frame
32
+
33
+ @renderdoc.trigger_capture
34
+ @triggered = true
35
+ true
36
+ end
37
+
38
+ alias tick call
39
+ end
40
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated from RenderDoc's renderdoc_app.h by script/generate_native_api.rb.
4
+ # Source: RenderDoc v1.25, RENDERDOC_API_1_6_0. Do not edit by hand.
5
+ module RenderDoc
6
+ module Native
7
+ API_VERSION = 10_600
8
+
9
+ API_TABLE_FIELDS = %i[
10
+ GetAPIVersion
11
+ SetCaptureOptionU32
12
+ SetCaptureOptionF32
13
+ GetCaptureOptionU32
14
+ GetCaptureOptionF32
15
+ SetFocusToggleKeys
16
+ SetCaptureKeys
17
+ GetOverlayBits
18
+ MaskOverlayBits
19
+ RemoveHooks
20
+ UnloadCrashHandler
21
+ SetCaptureFilePathTemplate
22
+ GetCaptureFilePathTemplate
23
+ GetNumCaptures
24
+ GetCapture
25
+ TriggerCapture
26
+ IsTargetControlConnected
27
+ LaunchReplayUI
28
+ SetActiveWindow
29
+ StartFrameCapture
30
+ IsFrameCapturing
31
+ EndFrameCapture
32
+ TriggerMultiFrameCapture
33
+ SetCaptureFileComments
34
+ DiscardFrameCapture
35
+ ShowReplayUI
36
+ SetCaptureTitle
37
+ ].freeze
38
+
39
+ FUNCTION_SIGNATURES = {
40
+ GetAPIVersion: [:void, %i[pointer pointer pointer]],
41
+ SetCaptureOptionU32: [:int, %i[int uint32]],
42
+ SetCaptureOptionF32: [:int, %i[int float]],
43
+ GetCaptureOptionU32: [:uint32, %i[int]],
44
+ GetCaptureOptionF32: [:float, %i[int]],
45
+ SetFocusToggleKeys: [:void, %i[pointer int]],
46
+ SetCaptureKeys: [:void, %i[pointer int]],
47
+ GetOverlayBits: [:uint32, []],
48
+ MaskOverlayBits: [:void, %i[uint32 uint32]],
49
+ RemoveHooks: [:void, []],
50
+ UnloadCrashHandler: [:void, []],
51
+ SetCaptureFilePathTemplate: [:void, %i[string]],
52
+ GetCaptureFilePathTemplate: [:pointer, []],
53
+ GetNumCaptures: [:uint32, []],
54
+ GetCapture: [:uint32, %i[uint32 pointer pointer pointer]],
55
+ TriggerCapture: [:void, []],
56
+ IsTargetControlConnected: [:uint32, []],
57
+ LaunchReplayUI: [:uint32, %i[uint32 string]],
58
+ SetActiveWindow: [:void, %i[pointer pointer]],
59
+ StartFrameCapture: [:void, %i[pointer pointer]],
60
+ IsFrameCapturing: [:uint32, []],
61
+ EndFrameCapture: [:uint32, %i[pointer pointer]],
62
+ TriggerMultiFrameCapture: [:void, %i[uint32]],
63
+ SetCaptureFileComments: [:void, %i[string string]],
64
+ DiscardFrameCapture: [:uint32, %i[pointer pointer]],
65
+ ShowReplayUI: [:uint32, []],
66
+ SetCaptureTitle: [:void, %i[string]]
67
+ }.freeze
68
+
69
+ CAPTURE_OPTIONS = {
70
+ :allow_vsync => 0,
71
+ :allow_fullscreen => 1,
72
+ :api_validation => 2,
73
+ :debug_device_mode => 2,
74
+ :capture_callstacks => 3,
75
+ :capture_callstacks_only_draws => 4,
76
+ :capture_callstacks_only_actions => 4,
77
+ :delay_for_debugger => 5,
78
+ :verify_buffer_access => 6,
79
+ :verify_map_writes => 6,
80
+ :hook_into_children => 7,
81
+ :ref_all_resources => 8,
82
+ :save_all_initials => 9,
83
+ :capture_all_cmd_lists => 10,
84
+ :debug_output_mute => 11,
85
+ :allow_unsupported_vendor_extensions => 12
86
+ }.freeze
87
+
88
+ INPUT_BUTTONS = {
89
+ :"0" => 48,
90
+ :"1" => 49,
91
+ :"2" => 50,
92
+ :"3" => 51,
93
+ :"4" => 52,
94
+ :"5" => 53,
95
+ :"6" => 54,
96
+ :"7" => 55,
97
+ :"8" => 56,
98
+ :"9" => 57,
99
+ :a => 65,
100
+ :b => 66,
101
+ :c => 67,
102
+ :d => 68,
103
+ :e => 69,
104
+ :f => 70,
105
+ :g => 71,
106
+ :h => 72,
107
+ :i => 73,
108
+ :j => 74,
109
+ :k => 75,
110
+ :l => 76,
111
+ :m => 77,
112
+ :n => 78,
113
+ :o => 79,
114
+ :p => 80,
115
+ :q => 81,
116
+ :r => 82,
117
+ :s => 83,
118
+ :t => 84,
119
+ :u => 85,
120
+ :v => 86,
121
+ :w => 87,
122
+ :x => 88,
123
+ :y => 89,
124
+ :z => 90,
125
+ :divide => 257,
126
+ :multiply => 258,
127
+ :subtract => 259,
128
+ :plus => 260,
129
+ :f1 => 261,
130
+ :f2 => 262,
131
+ :f3 => 263,
132
+ :f4 => 264,
133
+ :f5 => 265,
134
+ :f6 => 266,
135
+ :f7 => 267,
136
+ :f8 => 268,
137
+ :f9 => 269,
138
+ :f10 => 270,
139
+ :f11 => 271,
140
+ :f12 => 272,
141
+ :home => 273,
142
+ :end => 274,
143
+ :insert => 275,
144
+ :delete => 276,
145
+ :page_up => 277,
146
+ :page_down => 278,
147
+ :backspace => 279,
148
+ :tab => 280,
149
+ :print_screen => 281,
150
+ :pause => 282
151
+ }.freeze
152
+ end
153
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ffi"
4
+
5
+ require_relative "errors"
6
+ require_relative "native/generated"
7
+
8
+ module RenderDoc
9
+ module Native
10
+ extend FFI::Library
11
+
12
+ class APITable < FFI::Struct
13
+ layout(*API_TABLE_FIELDS.flat_map { |field| [field, :pointer] })
14
+ end
15
+
16
+ class API
17
+ def initialize(pointer, owner: nil)
18
+ @table = APITable.new(pointer)
19
+ @owner = owner
20
+ @functions = {}
21
+ end
22
+
23
+ def call(name, *arguments)
24
+ function(name).call(*arguments)
25
+ end
26
+
27
+ private
28
+
29
+ def function(name)
30
+ @functions[name] ||= begin
31
+ return_type, argument_types = FUNCTION_SIGNATURES.fetch(name)
32
+ pointer = @table[name]
33
+ raise VersionError, "RenderDoc returned a null pointer for #{name}" if pointer.null?
34
+
35
+ FFI::Function.new(return_type, argument_types, pointer)
36
+ end
37
+ end
38
+ end
39
+
40
+ class WindowsAPI
41
+ KERNEL_LIBRARY = "kernel32.dll"
42
+
43
+ def initialize(dynamic_library: FFI::DynamicLibrary)
44
+ flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_LOCAL
45
+ @kernel = dynamic_library.open(KERNEL_LIBRARY, flags)
46
+ @get_module_handle = windows_function(:pointer, [:string], "GetModuleHandleA")
47
+ @get_proc_address = windows_function(:pointer, %i[pointer string], "GetProcAddress")
48
+ end
49
+
50
+ def renderdoc_api_pointer
51
+ handle = @get_module_handle.call("renderdoc.dll")
52
+ return if handle.null?
53
+
54
+ pointer = @get_proc_address.call(handle, "RENDERDOC_GetAPI")
55
+ pointer unless pointer.null?
56
+ end
57
+
58
+ private
59
+
60
+ def windows_function(return_type, argument_types, name)
61
+ FFI::Function.new(return_type, argument_types, @kernel.find_function(name), convention: :stdcall)
62
+ end
63
+ end
64
+
65
+ class Loader
66
+ LINUX_LIBRARY = "librenderdoc.so"
67
+ GET_API_SYMBOL = "RENDERDOC_GetAPI"
68
+
69
+ def initialize(platform: RUBY_PLATFORM, dynamic_library: FFI::DynamicLibrary, windows_api: nil)
70
+ @platform = platform
71
+ @dynamic_library = dynamic_library
72
+ @windows_api = windows_api
73
+ end
74
+
75
+ def load
76
+ return if macos?
77
+
78
+ function_pointer, owner = get_api_pointer
79
+ return unless function_pointer
80
+
81
+ request_api(function_pointer, owner)
82
+ end
83
+
84
+ private
85
+
86
+ def get_api_pointer
87
+ return [windows_api.renderdoc_api_pointer, windows_api] if windows?
88
+
89
+ library = open_loaded_library(LINUX_LIBRARY)
90
+ return [nil, nil] unless library
91
+
92
+ [library.find_function(GET_API_SYMBOL), library]
93
+ rescue FFI::NotFoundError
94
+ [nil, nil]
95
+ end
96
+
97
+ def open_loaded_library(name)
98
+ flags = FFI::DynamicLibrary::RTLD_NOW | FFI::DynamicLibrary::RTLD_NOLOAD
99
+ @dynamic_library.open(name, flags)
100
+ rescue LoadError
101
+ nil
102
+ end
103
+
104
+ def request_api(function_pointer, owner)
105
+ get_api = FFI::Function.new(:int, %i[int pointer], function_pointer)
106
+ output = FFI::MemoryPointer.new(:pointer)
107
+ result = get_api.call(API_VERSION, output)
108
+ pointer = output.read_pointer
109
+
110
+ if result.zero? || pointer.null?
111
+ raise VersionError, "Loaded RenderDoc does not support the required in-application API 1.6.0"
112
+ end
113
+
114
+ API.new(pointer, owner: owner)
115
+ end
116
+
117
+ def windows_api
118
+ @windows_api ||= WindowsAPI.new(dynamic_library: @dynamic_library)
119
+ end
120
+
121
+ def windows?
122
+ @platform.match?(/mswin|mingw|cygwin/i)
123
+ end
124
+
125
+ def macos?
126
+ @platform.include?("darwin")
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+
5
+ require_relative "../renderdoc"
6
+ require_relative "rspec_integration"
7
+
8
+ RenderDoc::RSpecIntegration.install!
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ module RSpecIntegration
5
+ class FailureCapture
6
+ def initialize(renderdoc: RenderDoc)
7
+ @renderdoc = renderdoc
8
+ end
9
+
10
+ def capture(example)
11
+ return unless example.exception
12
+ return unless @renderdoc.available?
13
+
14
+ captures_before = @renderdoc.captures.length
15
+ rerun_example_in_capture(example)
16
+ report_capture(example, captures_before)
17
+ rescue RenderDoc::Error => error
18
+ example.reporter.message("RenderDoc capture failed: #{error.message}")
19
+ end
20
+
21
+ private
22
+
23
+ def rerun_example_in_capture(example)
24
+ block = example.instance_variable_get(:@example_block)
25
+ raise RenderDoc::CaptureError, "RSpec example block is unavailable" unless block
26
+
27
+ @renderdoc.capture(title: example.full_description) do
28
+ rerun_example_block(example, block)
29
+ end
30
+ end
31
+
32
+ def rerun_example_block(example, block)
33
+ example.example_group_instance.instance_exec(example, &block)
34
+ rescue Exception => error # rubocop:disable Lint/RescueException -- RSpec failures inherit directly from Exception
35
+ raise if error.is_a?(SystemExit) || error.is_a?(SignalException) || error.is_a?(NoMemoryError)
36
+
37
+ nil
38
+ end
39
+
40
+ def report_capture(example, captures_before)
41
+ captures = @renderdoc.captures
42
+ capture = captures.drop(captures_before).last || captures.last
43
+ message = capture ? "RenderDoc capture: #{capture.path}" : "RenderDoc capture completed"
44
+ example.reporter.message(message)
45
+ end
46
+ end
47
+
48
+ class << self
49
+ def install!(configuration = ::RSpec.configuration)
50
+ @installed_configurations ||= {}
51
+ return if @installed_configurations[configuration.object_id]
52
+
53
+ configuration.append_after(:each, :renderdoc_on_failure) do |example|
54
+ FailureCapture.new.capture(example)
55
+ end
56
+ @installed_configurations[configuration.object_id] = true
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ module Settings
5
+ def overlay
6
+ with_api(default: false) { |api| (api.call(:GetOverlayBits) & 1) == 1 }
7
+ end
8
+
9
+ def overlay=(enabled)
10
+ with_api(default: false) do |api|
11
+ api.call(:MaskOverlayBits, 0, enabled ? 0xF : 0)
12
+ true
13
+ end
14
+ end
15
+
16
+ def capture_keys=(keys)
17
+ set_keys(:SetCaptureKeys, keys)
18
+ end
19
+
20
+ def focus_toggle_keys=(keys)
21
+ set_keys(:SetFocusToggleKeys, keys)
22
+ end
23
+
24
+ def option(name, value)
25
+ option_id = enum_value(Native::CAPTURE_OPTIONS, name, "capture option")
26
+ function, native_value = option_argument(value)
27
+ with_api(default: false) { |api| !api.call(function, option_id, native_value).zero? }
28
+ end
29
+
30
+ def option_value(name, type: :uint32)
31
+ option_id = enum_value(Native::CAPTURE_OPTIONS, name, "capture option")
32
+ function = {uint32: :GetCaptureOptionU32, float: :GetCaptureOptionF32}.fetch(type) do
33
+ raise ArgumentError, "type must be :uint32 or :float"
34
+ end
35
+ with_api { |api| api.call(function, option_id) }
36
+ end
37
+
38
+ private
39
+
40
+ def set_keys(function, keys)
41
+ values = Array(keys).map { |key| enum_value(Native::INPUT_BUTTONS, key, "input button") }
42
+ pointer = values.empty? ? NULL_POINTER : FFI::MemoryPointer.new(:int, values.length)
43
+ pointer.write_array_of_int(values) unless values.empty?
44
+ with_api(default: false) { |api| api.call(function, pointer, values.length); true }
45
+ end
46
+
47
+ def enum_value(mapping, name, kind)
48
+ mapping.fetch(name.to_s.downcase.to_sym)
49
+ rescue KeyError
50
+ raise ArgumentError, "unknown RenderDoc #{kind}: #{name.inspect}"
51
+ end
52
+
53
+ def option_argument(value)
54
+ return [:SetCaptureOptionF32, value] if value.is_a?(Float)
55
+ return [:SetCaptureOptionU32, value ? 1 : 0] if value == true || value == false
56
+ return [:SetCaptureOptionU32, value] if value.is_a?(Integer) && value.between?(0, UINT32_MAX)
57
+
58
+ raise ArgumentError, "option value must be a boolean, Float, or unsigned 32-bit Integer"
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RenderDoc
4
+ VERSION = "0.1.0"
5
+ end
data/lib/renderdoc.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "renderdoc/version"
4
+ require_relative "renderdoc/errors"
5
+ require_relative "renderdoc/application"
6
+ require_relative "renderdoc/frame_trigger"
7
+
8
+ module RenderDoc
9
+ class << self
10
+ def configure(unavailable: nil)
11
+ configuration.unavailable_behavior = unavailable if unavailable
12
+ yield configuration if block_given?
13
+ configuration
14
+ end
15
+
16
+ def available? = application.available?
17
+ def api_version = application.api_version
18
+ def capture(**arguments, &block) = application.capture(**arguments, &block)
19
+ def trigger_capture(frames: 1) = application.trigger_capture(frames: frames)
20
+ def capture_path_template = application.capture_path_template
21
+ def capture_path_template=(path)
22
+ application.capture_path_template = path
23
+ end
24
+ def captures = application.captures
25
+ def launch_replay_ui(**arguments) = application.launch_replay_ui(**arguments)
26
+ def target_control_connected? = application.target_control_connected?
27
+ def show_replay_ui = application.show_replay_ui
28
+ def comments=(comments)
29
+ application.comments = comments
30
+ end
31
+ def set_capture_comments(comments, path: nil) = application.set_capture_comments(comments, path: path)
32
+ def overlay = application.overlay
33
+ def overlay=(enabled)
34
+ application.overlay = enabled
35
+ end
36
+
37
+ def capture_keys=(keys)
38
+ application.capture_keys = keys
39
+ end
40
+
41
+ def focus_toggle_keys=(keys)
42
+ application.focus_toggle_keys = keys
43
+ end
44
+ def option(name, value) = application.option(name, value)
45
+ def option_value(name, type: :uint32) = application.option_value(name, type: type)
46
+
47
+ private
48
+
49
+ def configuration
50
+ @configuration ||= Configuration.new
51
+ end
52
+
53
+ def application
54
+ @application ||= Application.new(configuration: configuration)
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Usage: ruby script/generate_native_api.rb path/to/renderdoc_app.h [output]
5
+ #
6
+ # The committed binding was generated from:
7
+ # https://raw.githubusercontent.com/baldurk/renderdoc/v1.25/renderdoc/api/app/renderdoc_app.h
8
+
9
+ HEADER_PATH = ARGV.fetch(0) do
10
+ warn "usage: #{$PROGRAM_NAME} path/to/renderdoc_app.h [output]"
11
+ exit 1
12
+ end
13
+ OUTPUT_PATH = ARGV[1]
14
+
15
+ TYPE_MAP = {
16
+ "float" => ":float",
17
+ "int" => ":int",
18
+ "uint32_t" => ":uint32",
19
+ "uint64_t" => ":uint64",
20
+ "void" => ":void"
21
+ }.freeze
22
+
23
+ def without_comments(source)
24
+ source.gsub(%r{/\*.*?\*/}m, "").gsub(%r{//[^\n]*}, "")
25
+ end
26
+
27
+ def ffi_type(c_type, return_type: false)
28
+ normalized = c_type.gsub(/\s+/, " ").strip
29
+ return ":pointer" if normalized.include?("*") && (return_type || !normalized.start_with?("const char"))
30
+ return ":string" if normalized.start_with?("const char")
31
+ return ":pointer" if normalized.match?(/RENDERDOC_(DevicePointer|WindowHandle)/)
32
+ return ":int" if normalized.match?(/RENDERDOC_(CaptureOption|InputButton|Version)/)
33
+
34
+ base_type = normalized.sub(/\s+[a-zA-Z_]\w*\z/, "")
35
+ TYPE_MAP.fetch(base_type) { raise "unsupported C type: #{c_type.inspect}" }
36
+ end
37
+
38
+ def parse_signatures(header)
39
+ typedef_pattern = /typedef\s+([^;{}]+?)\(RENDERDOC_CC\s+\*pRENDERDOC_(\w+)\)\s*\((.*?)\)\s*;/m
40
+
41
+ without_comments(header).scan(typedef_pattern).to_h do |return_type, name, arguments|
42
+ argument_types = arguments.strip
43
+ argument_types = argument_types.split(",").map { |argument| ffi_type(argument) } unless argument_types.empty? || argument_types == "void"
44
+ argument_types = [] unless argument_types.is_a?(Array)
45
+ [name, [ffi_type(return_type, return_type: true), argument_types]]
46
+ end
47
+ end
48
+
49
+ def parse_table_fields(header)
50
+ body = header.match(/typedef struct RENDERDOC_API_1_6_0\s*\{(.*?)\}\s*RENDERDOC_API_1_6_0\s*;/m)&.captures&.first
51
+ raise "RENDERDOC_API_1_6_0 was not found" unless body
52
+
53
+ body = without_comments(body).gsub(/union\s*\{(.*?)\}\s*;/m) do
54
+ Regexp.last_match(1).scan(/(pRENDERDOC_\w+)\s+(\w+)\s*;/).last.join(" ") + ";"
55
+ end
56
+ body.scan(/pRENDERDOC_\w+\s+(\w+)\s*;/).flatten
57
+ end
58
+
59
+ def parse_enum(header, name)
60
+ body = without_comments(header).match(/typedef enum #{name}\s*\{(.*?)\}\s*#{name}\s*;/m)&.captures&.first
61
+ raise "#{name} was not found" unless body
62
+
63
+ values = {}
64
+ current_value = -1
65
+ body.split(",").each do |entry|
66
+ next if entry.strip.empty?
67
+
68
+ constant, expression = entry.strip.split("=", 2).map(&:strip)
69
+ current_value = if expression
70
+ values.fetch(expression) { Integer(expression, 0) }
71
+ else
72
+ current_value + 1
73
+ end
74
+ values[constant] = current_value
75
+ end
76
+ values
77
+ end
78
+
79
+ def snake_case(name)
80
+ name.gsub(/([a-z\d])([A-Z])/, "\\1_\\2").downcase
81
+ end
82
+
83
+ def option_name(constant)
84
+ snake_case(constant.delete_prefix("eRENDERDOC_Option_"))
85
+ .gsub("v_sync", "vsync")
86
+ .gsub("apivalidation", "api_validation")
87
+ end
88
+
89
+ def key_name(constant)
90
+ name = snake_case(constant.delete_prefix("eRENDERDOC_Key_"))
91
+ {"page_dn" => "page_down", "prt_scrn" => "print_screen"}.fetch(name, name)
92
+ end
93
+
94
+ def format_symbol_array(values, indent: 6)
95
+ padding = " " * indent
96
+ values.map { |value| "#{padding}#{value}" }.join("\n")
97
+ end
98
+
99
+ def format_hash(values, key_transform, reject: [])
100
+ entries = values.filter_map do |constant, value|
101
+ next if reject.include?(constant)
102
+
103
+ " #{key_transform.call(constant).to_sym.inspect} => #{value}"
104
+ end
105
+ entries.join(",\n")
106
+ end
107
+
108
+ header = File.read(HEADER_PATH)
109
+ fields = parse_table_fields(header)
110
+ signatures = parse_signatures(header)
111
+ missing_signatures = fields.reject { |field| signatures.key?(field) }
112
+ raise "missing signatures: #{missing_signatures.join(', ')}" unless missing_signatures.empty?
113
+
114
+ options = parse_enum(header, "RENDERDOC_CaptureOption")
115
+ keys = parse_enum(header, "RENDERDOC_InputButton")
116
+
117
+ function_signatures = fields.map do |field|
118
+ return_type, argument_types = signatures.fetch(field)
119
+ arguments = argument_types.empty? ? "[]" : "%i[#{argument_types.map { |type| type.delete_prefix(':') }.join(' ')}]"
120
+ " #{field}: [#{return_type}, #{arguments}]"
121
+ end.join(",\n")
122
+
123
+ generated = <<~RUBY
124
+ # frozen_string_literal: true
125
+
126
+ # Generated from RenderDoc's renderdoc_app.h by script/generate_native_api.rb.
127
+ # Source: RenderDoc v1.25, RENDERDOC_API_1_6_0. Do not edit by hand.
128
+ module RenderDoc
129
+ module Native
130
+ API_VERSION = 10_600
131
+
132
+ API_TABLE_FIELDS = %i[
133
+ #{format_symbol_array(fields)}
134
+ ].freeze
135
+
136
+ FUNCTION_SIGNATURES = {
137
+ #{function_signatures}
138
+ }.freeze
139
+
140
+ CAPTURE_OPTIONS = {
141
+ #{format_hash(options, method(:option_name))}
142
+ }.freeze
143
+
144
+ INPUT_BUTTONS = {
145
+ #{format_hash(keys, method(:key_name), reject: %w[eRENDERDOC_Key_NonPrintable eRENDERDOC_Key_Max])}
146
+ }.freeze
147
+ end
148
+ end
149
+ RUBY
150
+
151
+ if OUTPUT_PATH
152
+ File.write(OUTPUT_PATH, generated)
153
+ else
154
+ puts generated
155
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: renderdoc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Yudai Takada
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.17'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.17'
26
+ description: Programmatically capture and inspect GPU frames from Ruby applications
27
+ with RenderDoc.
28
+ email:
29
+ - t.yudai92@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - LICENSE.txt
35
+ - README.md
36
+ - Rakefile
37
+ - lib/renderdoc.rb
38
+ - lib/renderdoc/application.rb
39
+ - lib/renderdoc/capture.rb
40
+ - lib/renderdoc/configuration.rb
41
+ - lib/renderdoc/errors.rb
42
+ - lib/renderdoc/frame_trigger.rb
43
+ - lib/renderdoc/native.rb
44
+ - lib/renderdoc/native/generated.rb
45
+ - lib/renderdoc/rspec.rb
46
+ - lib/renderdoc/rspec_integration.rb
47
+ - lib/renderdoc/settings.rb
48
+ - lib/renderdoc/version.rb
49
+ - script/generate_native_api.rb
50
+ homepage: https://github.com/ydah/renderdoc
51
+ licenses:
52
+ - MIT
53
+ metadata:
54
+ homepage_uri: https://github.com/ydah/renderdoc
55
+ source_code_uri: https://github.com/ydah/renderdoc/tree/main
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: 3.2.0
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 4.0.6
72
+ specification_version: 4
73
+ summary: Ruby bindings for RenderDoc's in-application API
74
+ test_files: []