metaco 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fafdd0cb9aab0b39bca483167bb12a6caf1bedea3f4270af88badfd21f2e0ab7
4
- data.tar.gz: 4f41f6f4b2ea9b826c799abddf2cfcb09d8d64724e6ac910a45237596774cb8b
3
+ metadata.gz: 56719f087b9757be4e9f9b47df20a2be5e48e30fffe781c130c982a4d5da0593
4
+ data.tar.gz: 9be3b5f19b8d255953964097538d705fbd00ca184b7301ecc51acbeeecad6f49
5
5
  SHA512:
6
- metadata.gz: 229d3371dc99c8248aa8e676264d3f67c240542d22ea676ff9ef90a1ff60e11770d1ff34d059c0eb14f9a3f0b29ce957e247c61df46e556df69c6c22826a47c2
7
- data.tar.gz: e5c641c5d10a0dc278f2405e5f61b8e8fc4097450bf441fe56601953576d963ec8ec7dd30622c8a20be7be8ced73ff61137e31551840548263b01daf3cae58f3
6
+ metadata.gz: fb8667d5665d9392cd1d8efec9128211f83be708e82f2a8eebcfe3623fc2deb3dd4785daf8756e286d6edd378cfe316d2898fd7def72e94a777d846cee13a0d1
7
+ data.tar.gz: c83bd6e0bf4164c9af4028e7103219d4005a7bd6df06fab57dc8db365381692fd986ba5a9c8d2d5740fa82b835470df504c1eecd8fb054dc98e0c7dcf3ecb810
data/CHANGELOG.md CHANGED
@@ -1,7 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.2.0 - 2026-09-09
4
+
5
+ - Replace integer window pointers with GC-managed `Metaco::Window` handles. Validate closed handles, main-thread access, dimensions, and pixel buffer capacity.
6
+ - Preserve Ruby exception cleanup, UTF-8/NUL key input, and right/middle mouse and drag events. Complete the unsupported-platform API without hiding macOS load errors.
7
+ - Preserve RGBA colors through a Metal render pass, synchronize GPU work without holding the GVL, propagate GPU errors, and validate all rendering/compute allocations.
8
+ - Keep the previous shader on recompilation failure; reject uniforms over 256 bytes and zero unused bytes. Dispatch exactly the image dimensions within pipeline limits.
9
+ - Add native pixel readback and fault-injection regressions; run GUI tests in macOS CI and support mandatory Metal validation on a GPU runner.
4
10
 
5
11
  ## 0.1.0 - 2026-01-02
6
12
 
7
- - Initial release.
13
+ - Initial release.
data/README.md CHANGED
@@ -87,6 +87,7 @@ if Metaco.metal_compute_available?(handle)
87
87
  constant float4 &uniforms [[buffer(0)]],
88
88
  uint2 gid [[thread_position_in_grid]])
89
89
  {
90
+ if (gid.x >= output.get_width() || gid.y >= output.get_height()) return;
90
91
  float2 uv = float2(gid) / float2(output.get_width(), output.get_height());
91
92
  output.write(float4(uv.x, uv.y, uniforms.x, 1.0), gid);
92
93
  }
@@ -114,8 +115,8 @@ Metaco.window_destroy(handle)
114
115
  | Method | Description |
115
116
  |--------|-------------|
116
117
  | `init` | Initialize Cocoa application |
117
- | `window_create(width, height, title)` | Create a new window, returns handle |
118
- | `window_destroy(handle)` | Destroy the window |
118
+ | `window_create(width, height, title)` | Create a new window, returns a `Metaco::Window` handle |
119
+ | `window_destroy(handle)` | Close and release the window; repeated calls are safe |
119
120
  | `should_close?(handle)` | Check if window should close |
120
121
  | `poll_events(handle)` | Poll and return pending events |
121
122
 
@@ -124,7 +125,7 @@ Metaco.window_destroy(handle)
124
125
  | Method | Description |
125
126
  |--------|-------------|
126
127
  | `set_pixels(handle, buffer, width, height)` | Set pixel data (RGBA format) |
127
- | `present(handle)` | Present the frame |
128
+ | `present(handle)` | Present the frame and wait for GPU completion |
128
129
 
129
130
  ### Compute Shaders
130
131
 
@@ -132,8 +133,8 @@ Metaco.window_destroy(handle)
132
133
  |--------|-------------|
133
134
  | `metal_compute_available?(handle)` | Check if Metal compute is available |
134
135
  | `compile_compute_shader(handle, msl_source)` | Compile MSL compute shader |
135
- | `dispatch_compute(handle, uniforms)` | Execute compute shader |
136
- | `present_compute(handle)` | Present compute shader output |
136
+ | `dispatch_compute(handle, uniforms)` | Execute compute shader and wait for GPU completion |
137
+ | `present_compute(handle)` | Present compute shader output and wait for GPU completion |
137
138
  | `has_compute_shader?(handle)` | Check if shader is compiled |
138
139
 
139
140
  ### Event Types
@@ -144,6 +145,19 @@ Metaco.window_destroy(handle)
144
145
  - `:mouse_release` - Mouse button released (`:x`, `:y`, `:button`)
145
146
  - `:mouse_move` - Mouse moved (`:x`, `:y`)
146
147
 
148
+ ### API contracts
149
+
150
+ - Call `init` before creating windows. All native APIs must run on the process's main thread; worker-thread calls raise `ThreadError`. GPU completion waits release Ruby's GVL.
151
+ - Handles are opaque `Metaco::Window` objects, replacing the integer pointers returned by 0.1.0. Pass them unchanged to Metaco methods. A closed handle raises `ArgumentError` on operations other than `window_destroy`; unrelated objects raise `TypeError`.
152
+ - Release windows in an `ensure` block. GC also releases abandoned windows, scheduling AppKit cleanup on the main thread when necessary; `poll_events` services this queue.
153
+ - Width and height must be between 1 and 16,384. `set_pixels` requires the original window dimensions and at least `width * height * 4` bytes of row-major RGBA data, with unpremultiplied alpha. Extra trailing bytes are ignored. Invalid dimensions or short buffers raise `ArgumentError`.
154
+ - Titles and shader sources must contain valid UTF-8. Key event `:char` strings use UTF-8 and preserve embedded NUL characters. Mouse buttons are numbered 0 (left), 1 (right), and 2 (middle); dragging produces `:mouse_move` events.
155
+ - Compute shaders use the entry point `compute_shader`, output texture 0, and a uniform buffer at index 0. Uniforms may contain 0–256 bytes; remaining bytes are zeroed on every dispatch. Larger inputs raise `ArgumentError`. Shader uniform structures must fit within 256 bytes.
156
+ - Failed compilation preserves the previous shader and its resources. Dispatch and compute presentation require a compiled shader. GPU command failures raise `RuntimeError` after native resources have been cleaned up.
157
+ - Dispatch covers exactly the window's pixels. Threadgroup dimensions may vary by pipeline and image width; shaders must not assume a fixed group size. The example's bounds check also makes it safe with other dispatch implementations.
158
+ - Presentation is synchronous so pixel uploads cannot overwrite an in-flight frame. An occluded window may have no drawable, in which case presentation is skipped. Rendering resource allocation failures select the bitmap fallback; compute availability then returns `false`.
159
+ - On unsupported platforms both compute availability queries return `false`, and other APIs raise `LoadError`. On macOS, native extension loading errors retain their original diagnostics.
160
+
147
161
  ## Development
148
162
 
149
163
  ```bash
@@ -155,8 +169,15 @@ bundle exec rake compile
155
169
 
156
170
  # Run tests
157
171
  bundle exec rake test
172
+
173
+ # Require actual GPU execution and validate Metal commands/shaders
174
+ METACO_REQUIRE_METAL=1 MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 bundle exec rake test
158
175
  ```
159
176
 
177
+ The test task includes a separate native test extension for GPU pixel readback, allocation and command failure injection, Unicode input, and GC/exception cleanup. These helpers are excluded from the gem. CI runs window and bitmap tests on macOS even when Metal is unavailable; only GPU-specific tests are omitted in that case. Set `METACO_SKIP_GUI=1` explicitly for a session without WindowServer. Argument and fallback tests still run.
178
+
179
+ The optional `run_metal` workflow-dispatch input runs the same suite with mandatory GPU execution on a self-hosted runner labeled `macOS` and `metal`. It requires a logged-in GUI session and a Metal device.
180
+
160
181
  ## License
161
182
 
162
183
  The gem is available as open source under the terms of the [MIT License](LICENSE).
data/Rakefile CHANGED
@@ -3,6 +3,7 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/extensiontask"
5
5
  require "rake/testtask"
6
+ require "rbconfig"
6
7
 
7
8
  Rake::ExtensionTask.new("metaco") do |ext|
8
9
  ext.lib_dir = "lib/metaco"
@@ -14,5 +15,18 @@ Rake::TestTask.new(:test) do |t|
14
15
  t.test_files = FileList["test/**/test_*.rb"]
15
16
  end
16
17
 
17
- task test: :compile
18
+ desc "Run native regression tests with GPU readback and fault injection"
19
+ task :test_native do
20
+ next unless RUBY_PLATFORM.include?("darwin")
21
+
22
+ build_dir = File.expand_path("tmp/native/#{RUBY_PLATFORM}/#{RUBY_VERSION}", __dir__)
23
+ mkdir_p build_dir
24
+ Dir.chdir(build_dir) do
25
+ ruby File.expand_path("test/native/extconf.rb", __dir__)
26
+ sh RbConfig::CONFIG.fetch("MAKE", "make")
27
+ end
28
+ ruby "-I#{build_dir}", "test/native/regressions.rb"
29
+ end
30
+
31
+ task test: [:compile, :test_native]
18
32
  task default: :test
data/ext/metaco/metaco.m CHANGED
@@ -6,53 +6,33 @@
6
6
  #import <Metal/Metal.h>
7
7
  #import <QuartzCore/CAMetalLayer.h>
8
8
  #import <ruby.h>
9
+ #import <ruby/thread.h>
10
+ #include <limits.h>
11
+ #include <pthread.h>
12
+ #include <stdint.h>
13
+
14
+ static void native_error(NSError **error, NSString *message) {
15
+ if (error) *error = [NSError errorWithDomain:@"Metaco" code:1
16
+ userInfo:@{NSLocalizedDescriptionKey: message}];
17
+ }
9
18
 
10
19
  @interface MetacoMetalView : NSView
11
20
  @property (nonatomic, strong) CAMetalLayer *metalLayer;
12
21
  @property (nonatomic, strong) id<MTLDevice> device;
13
22
  @property (nonatomic, strong) id<MTLCommandQueue> commandQueue;
14
23
  @property (nonatomic, strong) id<MTLTexture> texture;
24
+ @property (nonatomic, strong) id<MTLRenderPipelineState> renderPipeline;
15
25
  @property (nonatomic, assign) int texWidth;
16
26
  @property (nonatomic, assign) int texHeight;
17
- // Compute shader support
18
27
  @property (nonatomic, strong) id<MTLComputePipelineState> computePipeline;
19
28
  @property (nonatomic, strong) id<MTLBuffer> uniformBuffer;
20
29
  @property (nonatomic, strong) id<MTLTexture> outputTexture;
21
- @property (nonatomic, assign) BOOL hasComputeShader;
30
+ @property (nonatomic, readonly) BOOL hasComputeShader;
22
31
  @end
23
32
 
24
33
  @implementation MetacoMetalView
25
34
 
26
- - (void)cleanup {
27
- // Only cleanup if we have resources
28
- if (!self.device) return;
29
-
30
- // Wait for GPU to finish before releasing resources
31
- if (self.commandQueue) {
32
- @try {
33
- id<MTLCommandBuffer> syncBuffer = [self.commandQueue commandBuffer];
34
- if (syncBuffer) {
35
- [syncBuffer commit];
36
- [syncBuffer waitUntilCompleted];
37
- }
38
- } @catch (NSException *e) {
39
- // Ignore exceptions during cleanup
40
- }
41
- }
42
-
43
- // Clear all Metal resources
44
- self.computePipeline = nil;
45
- self.uniformBuffer = nil;
46
- self.outputTexture = nil;
47
- self.texture = nil;
48
- self.commandQueue = nil;
49
- self.metalLayer = nil;
50
- self.device = nil;
51
- self.hasComputeShader = NO;
52
- }
53
-
54
- // dealloc is handled automatically by ARC - no manual cleanup needed
55
- // cocoa_window_destroy handles synchronization with GPU before release
35
+ - (BOOL)hasComputeShader { return self.computePipeline != nil; }
56
36
 
57
37
  - (instancetype)initWithFrame:(NSRect)frame device:(id<MTLDevice>)device width:(int)w height:(int)h {
58
38
  self = [super initWithFrame:frame];
@@ -60,163 +40,155 @@
60
40
  self.device = device;
61
41
  self.texWidth = w;
62
42
  self.texHeight = h;
43
+ self.commandQueue = [device newCommandQueue];
44
+ if (!self.commandQueue) return nil;
45
+
46
+ // A render pass preserves RGBA channel meaning when writing a BGRA drawable.
47
+ NSString *source = @"#include <metal_stdlib>\n"
48
+ "using namespace metal;\n"
49
+ "vertex float4 metaco_vertex(uint i [[vertex_id]]) {\n"
50
+ " const float2 p[] = {float2(-1,-1), float2(3,-1), float2(-1,3)};\n"
51
+ " return float4(p[i], 0, 1);\n"
52
+ "}\n"
53
+ "fragment float4 metaco_fragment(float4 p [[position]],\n"
54
+ " texture2d<float> input [[texture(0)]]) { return input.read(uint2(p.xy)); }\n";
55
+ id<MTLLibrary> library = [device newLibraryWithSource:source options:nil error:nil];
56
+ if (!library) return nil;
57
+ MTLRenderPipelineDescriptor *pipeline = [MTLRenderPipelineDescriptor new];
58
+ pipeline.vertexFunction = [library newFunctionWithName:@"metaco_vertex"];
59
+ pipeline.fragmentFunction = [library newFunctionWithName:@"metaco_fragment"];
60
+ pipeline.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
61
+ if (!pipeline.vertexFunction || !pipeline.fragmentFunction) return nil;
62
+ self.renderPipeline = [device newRenderPipelineStateWithDescriptor:pipeline error:nil];
63
+ if (!self.renderPipeline) return nil;
64
+
65
+ MTLTextureDescriptor *desc = [MTLTextureDescriptor
66
+ texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm width:w height:h mipmapped:NO];
67
+ desc.usage = MTLTextureUsageShaderRead;
68
+ desc.storageMode = MTLStorageModeManaged;
69
+ if (@available(macOS 10.15, *)) {
70
+ if (device.hasUnifiedMemory) desc.storageMode = MTLStorageModeShared;
71
+ }
72
+ self.texture = [device newTextureWithDescriptor:desc];
73
+ if (!self.texture) return nil;
63
74
 
64
75
  self.wantsLayer = YES;
65
76
  self.metalLayer = [CAMetalLayer layer];
77
+ if (!self.metalLayer) return nil;
66
78
  self.metalLayer.device = device;
67
79
  self.metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm;
68
- self.metalLayer.framebufferOnly = NO;
69
- self.metalLayer.displaySyncEnabled = NO; // Disable VSync for max FPS
80
+ self.metalLayer.framebufferOnly = YES;
81
+ self.metalLayer.displaySyncEnabled = NO;
70
82
  self.metalLayer.frame = frame;
71
83
  self.metalLayer.drawableSize = CGSizeMake(w, h);
72
84
  self.layer = self.metalLayer;
73
-
74
- self.commandQueue = [device newCommandQueue];
75
-
76
- // Create texture for pixel data
77
- MTLTextureDescriptor *texDesc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm
78
- width:w
79
- height:h
80
- mipmapped:NO];
81
- texDesc.usage = MTLTextureUsageShaderRead;
82
- self.texture = [device newTextureWithDescriptor:texDesc];
83
85
  }
84
86
  return self;
85
87
  }
86
88
 
87
89
  - (void)updatePixels:(const uint8_t *)data {
88
- MTLRegion region = MTLRegionMake2D(0, 0, self.texWidth, self.texHeight);
89
- [self.texture replaceRegion:region mipmapLevel:0 withBytes:data bytesPerRow:self.texWidth * 4];
90
+ [self.texture replaceRegion:MTLRegionMake2D(0, 0, self.texWidth, self.texHeight)
91
+ mipmapLevel:0 withBytes:data bytesPerRow:(size_t)self.texWidth * 4];
90
92
  }
91
93
 
92
- - (void)present {
93
- id<CAMetalDrawable> drawable = [self.metalLayer nextDrawable];
94
- if (!drawable) return;
94
+ - (BOOL)renderTexture:(id<MTLTexture>)source toTexture:(id<MTLTexture>)target
95
+ commandBuffer:(id<MTLCommandBuffer>)commandBuffer error:(NSError **)error {
96
+ MTLRenderPassDescriptor *pass = [MTLRenderPassDescriptor renderPassDescriptor];
97
+ pass.colorAttachments[0].texture = target;
98
+ pass.colorAttachments[0].loadAction = MTLLoadActionDontCare;
99
+ pass.colorAttachments[0].storeAction = MTLStoreActionStore;
100
+ id<MTLRenderCommandEncoder> encoder = [commandBuffer renderCommandEncoderWithDescriptor:pass];
101
+ if (!encoder) {
102
+ native_error(error, @"Failed to create render encoder");
103
+ return NO;
104
+ }
105
+ [encoder setRenderPipelineState:self.renderPipeline];
106
+ [encoder setFragmentTexture:source atIndex:0];
107
+ [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3];
108
+ [encoder endEncoding];
109
+ return YES;
110
+ }
95
111
 
112
+ - (id<MTLCommandBuffer>)presentTexture:(id<MTLTexture>)texture error:(NSError **)error {
113
+ id<CAMetalDrawable> drawable = [self.metalLayer nextDrawable];
114
+ if (!drawable) return nil; // An occluded window can temporarily have no drawable.
96
115
  id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
97
-
98
- // Blit texture to drawable
99
- id<MTLBlitCommandEncoder> blitEncoder = [commandBuffer blitCommandEncoder];
100
-
101
- [blitEncoder copyFromTexture:self.texture
102
- sourceSlice:0
103
- sourceLevel:0
104
- sourceOrigin:MTLOriginMake(0, 0, 0)
105
- sourceSize:MTLSizeMake(self.texWidth, self.texHeight, 1)
106
- toTexture:drawable.texture
107
- destinationSlice:0
108
- destinationLevel:0
109
- destinationOrigin:MTLOriginMake(0, 0, 0)];
110
-
111
- [blitEncoder endEncoding];
112
-
116
+ if (!commandBuffer) {
117
+ native_error(error, @"Failed to create render command buffer");
118
+ return nil;
119
+ }
120
+ if (![self renderTexture:texture toTexture:drawable.texture commandBuffer:commandBuffer error:error]) return nil;
113
121
  [commandBuffer presentDrawable:drawable];
114
- [commandBuffer commit];
122
+ return commandBuffer;
115
123
  }
116
124
 
117
- #pragma mark - Compute Shader Support
118
-
119
125
  - (BOOL)compileComputeShader:(NSString *)mslSource error:(NSError **)error {
120
- // Compile MSL source to library
121
- id<MTLLibrary> library = [self.device newLibraryWithSource:mslSource
122
- options:nil
123
- error:error];
126
+ id<MTLLibrary> library = [self.device newLibraryWithSource:mslSource options:nil error:error];
124
127
  if (!library) return NO;
125
-
126
- // Get compute function
127
- id<MTLFunction> computeFunc = [library newFunctionWithName:@"compute_shader"];
128
- if (!computeFunc) {
129
- if (error) *error = [NSError errorWithDomain:@"Metaco" code:1
130
- userInfo:@{NSLocalizedDescriptionKey: @"compute_shader function not found"}];
128
+ id<MTLFunction> function = [library newFunctionWithName:@"compute_shader"];
129
+ if (!function) {
130
+ native_error(error, @"compute_shader function not found");
131
131
  return NO;
132
132
  }
133
+ id<MTLComputePipelineState> pipeline = [self.device newComputePipelineStateWithFunction:function error:error];
134
+ if (!pipeline) return NO;
133
135
 
134
- // Create compute pipeline
135
- self.computePipeline = [self.device newComputePipelineStateWithFunction:computeFunc error:error];
136
- if (!self.computePipeline) return NO;
137
-
138
- // Create output texture (writable)
139
- MTLTextureDescriptor *texDesc = [MTLTextureDescriptor
136
+ MTLTextureDescriptor *desc = [MTLTextureDescriptor
140
137
  texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm
141
- width:self.texWidth
142
- height:self.texHeight
143
- mipmapped:NO];
144
- texDesc.usage = MTLTextureUsageShaderWrite | MTLTextureUsageShaderRead;
145
- self.outputTexture = [self.device newTextureWithDescriptor:texDesc];
146
-
147
- // Create uniform buffer (256 bytes should be enough for most shaders)
148
- self.uniformBuffer = [self.device newBufferWithLength:256
149
- options:MTLResourceStorageModeShared];
138
+ width:self.texWidth height:self.texHeight mipmapped:NO];
139
+ desc.usage = MTLTextureUsageShaderWrite | MTLTextureUsageShaderRead;
140
+ desc.storageMode = MTLStorageModePrivate;
141
+ id<MTLTexture> output = [self.device newTextureWithDescriptor:desc];
142
+ id<MTLBuffer> uniforms = [self.device newBufferWithLength:256 options:MTLResourceStorageModeShared];
143
+ if (!output || !uniforms || !uniforms.contents) {
144
+ native_error(error, @"Failed to allocate compute resources");
145
+ return NO;
146
+ }
150
147
 
151
- self.hasComputeShader = YES;
148
+ // Publish the new state only after every resource has been created.
149
+ self.computePipeline = pipeline;
150
+ self.outputTexture = output;
151
+ self.uniformBuffer = uniforms;
152
152
  return YES;
153
153
  }
154
154
 
155
- - (void)dispatchComputeWithUniforms:(const void *)uniformData length:(NSUInteger)length {
156
- if (!self.hasComputeShader) return;
157
-
158
- // Update uniform buffer
159
- memcpy(self.uniformBuffer.contents, uniformData, MIN(length, 256));
160
-
161
- id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
162
- id<MTLComputeCommandEncoder> computeEncoder = [commandBuffer computeCommandEncoder];
163
-
164
- [computeEncoder setComputePipelineState:self.computePipeline];
165
- [computeEncoder setTexture:self.outputTexture atIndex:0];
166
- [computeEncoder setBuffer:self.uniformBuffer offset:0 atIndex:0];
167
-
168
- // Calculate optimal thread group size
169
- NSUInteger threadGroupSize = self.computePipeline.maxTotalThreadsPerThreadgroup;
170
- NSUInteger threadGroupWidth = 16;
171
- NSUInteger threadGroupHeight = 16;
172
-
173
- if (threadGroupWidth * threadGroupHeight > threadGroupSize) {
174
- threadGroupWidth = 8;
175
- threadGroupHeight = 8;
155
+ - (id<MTLCommandBuffer>)dispatchComputeWithUniforms:(const void *)data length:(NSUInteger)length
156
+ error:(NSError **)error {
157
+ if (!self.hasComputeShader || length > 256) {
158
+ native_error(error, @"Invalid compute state or uniform size");
159
+ return nil;
176
160
  }
177
-
178
- MTLSize threadsPerGroup = MTLSizeMake(threadGroupWidth, threadGroupHeight, 1);
179
- MTLSize threadGroups = MTLSizeMake(
180
- (self.texWidth + threadGroupWidth - 1) / threadGroupWidth,
181
- (self.texHeight + threadGroupHeight - 1) / threadGroupHeight,
182
- 1
183
- );
184
-
185
- [computeEncoder dispatchThreadgroups:threadGroups threadsPerThreadgroup:threadsPerGroup];
186
- [computeEncoder endEncoding];
187
-
188
- [commandBuffer commit];
189
- [commandBuffer waitUntilCompleted];
190
- }
191
-
192
- - (void)presentCompute {
193
- if (!self.hasComputeShader) return;
194
-
195
- id<CAMetalDrawable> drawable = [self.metalLayer nextDrawable];
196
- if (!drawable) return;
197
-
161
+ memset(self.uniformBuffer.contents, 0, 256);
162
+ memcpy(self.uniformBuffer.contents, data, length);
198
163
  id<MTLCommandBuffer> commandBuffer = [self.commandQueue commandBuffer];
199
- id<MTLBlitCommandEncoder> blitEncoder = [commandBuffer blitCommandEncoder];
200
-
201
- // Blit from compute output to drawable
202
- [blitEncoder copyFromTexture:self.outputTexture
203
- sourceSlice:0
204
- sourceLevel:0
205
- sourceOrigin:MTLOriginMake(0, 0, 0)
206
- sourceSize:MTLSizeMake(self.texWidth, self.texHeight, 1)
207
- toTexture:drawable.texture
208
- destinationSlice:0
209
- destinationLevel:0
210
- destinationOrigin:MTLOriginMake(0, 0, 0)];
211
-
212
- [blitEncoder endEncoding];
213
- [commandBuffer presentDrawable:drawable];
214
- [commandBuffer commit];
164
+ id<MTLComputeCommandEncoder> encoder = [commandBuffer computeCommandEncoder];
165
+ if (!commandBuffer || !encoder) {
166
+ native_error(error, @"Failed to create compute command buffer or encoder");
167
+ return nil;
168
+ }
169
+ [encoder setComputePipelineState:self.computePipeline];
170
+ [encoder setTexture:self.outputTexture atIndex:0];
171
+ [encoder setBuffer:self.uniformBuffer offset:0 atIndex:0];
172
+
173
+ NSUInteger limit = MIN(self.computePipeline.maxTotalThreadsPerThreadgroup,
174
+ self.device.maxThreadsPerThreadgroup.width);
175
+ if (!limit) {
176
+ [encoder endEncoding];
177
+ native_error(error, @"Compute pipeline has no supported threadgroup size");
178
+ return nil;
179
+ }
180
+ NSUInteger width = MAX((NSUInteger)1, MIN(self.computePipeline.threadExecutionWidth, limit));
181
+ // Use complete rows that divide the image, so even older GPUs dispatch no extra pixels.
182
+ while ((NSUInteger)self.texWidth % width != 0) width--;
183
+ [encoder dispatchThreadgroups:MTLSizeMake(self.texWidth / width, self.texHeight, 1)
184
+ threadsPerThreadgroup:MTLSizeMake(width, 1, 1)];
185
+ [encoder endEncoding];
186
+ return commandBuffer;
215
187
  }
216
188
 
217
189
  @end
218
190
 
219
- @interface MetacoWindow : NSWindow
191
+ @interface MetacoWindow : NSWindow <NSWindowDelegate>
220
192
  @property (nonatomic, assign) BOOL shouldClose;
221
193
  @property (nonatomic, strong) NSMutableArray *pendingEvents;
222
194
  @property (nonatomic, strong) MetacoMetalView *metalView;
@@ -237,23 +209,25 @@
237
209
  backing:NSBackingStoreBuffered
238
210
  defer:NO];
239
211
  if (self) {
212
+ self.releasedWhenClosed = NO;
240
213
  self.shouldClose = NO;
241
214
  self.pendingEvents = [NSMutableArray array];
242
215
  [self setTitle:title];
243
- [self setDelegate:(id<NSWindowDelegate>)self];
216
+ [self setDelegate:self];
244
217
 
245
218
  // Try to use Metal
246
219
  id<MTLDevice> device = MTLCreateSystemDefaultDevice();
247
220
  if (device) {
248
- self.useMetal = YES;
249
221
  self.metalView = [[MetacoMetalView alloc] initWithFrame:NSMakeRect(0, 0, width, height)
250
222
  device:device
251
223
  width:width
252
224
  height:height];
225
+ }
226
+ self.useMetal = self.metalView != nil;
227
+ if (self.useMetal) {
253
228
  [self setContentView:self.metalView];
254
229
  } else {
255
- // Fallback to bitmap
256
- self.useMetal = NO;
230
+ // Device or rendering resource creation failed: use the bitmap path.
257
231
  self.bitmapRep = [[NSBitmapImageRep alloc]
258
232
  initWithBitmapDataPlanes:NULL
259
233
  pixelsWide:width
@@ -263,13 +237,22 @@
263
237
  hasAlpha:YES
264
238
  isPlanar:NO
265
239
  colorSpaceName:NSDeviceRGBColorSpace
266
- bytesPerRow:width * 4
240
+ bitmapFormat:NSBitmapFormatAlphaNonpremultiplied
241
+ bytesPerRow:(size_t)width * 4
267
242
  bitsPerPixel:32];
268
243
 
244
+ if (!self.bitmapRep || !self.bitmapRep.bitmapData) {
245
+ [self close];
246
+ return nil;
247
+ }
269
248
  NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(width, height)];
270
249
  [image addRepresentation:self.bitmapRep];
271
250
 
272
251
  self.imageView = [[NSImageView alloc] initWithFrame:NSMakeRect(0, 0, width, height)];
252
+ if (!image || !self.imageView) {
253
+ [self close];
254
+ return nil;
255
+ }
273
256
  [self.imageView setImage:image];
274
257
  [self.imageView setImageScaling:NSImageScaleAxesIndependently];
275
258
  [self setContentView:self.imageView];
@@ -335,11 +318,79 @@
335
318
  [self.pendingEvents addObject:eventDict];
336
319
  }
337
320
 
321
+ - (void)rightMouseDown:(NSEvent *)event { [self mouseDown:event]; }
322
+ - (void)rightMouseUp:(NSEvent *)event { [self mouseUp:event]; }
323
+ - (void)otherMouseDown:(NSEvent *)event { [self mouseDown:event]; }
324
+ - (void)otherMouseUp:(NSEvent *)event { [self mouseUp:event]; }
325
+ - (void)mouseDragged:(NSEvent *)event { [self mouseMoved:event]; }
326
+ - (void)rightMouseDragged:(NSEvent *)event { [self mouseMoved:event]; }
327
+ - (void)otherMouseDragged:(NSEvent *)event { [self mouseMoved:event]; }
328
+
338
329
  - (BOOL)canBecomeKeyWindow { return YES; }
339
330
  - (BOOL)acceptsFirstResponder { return YES; }
340
331
  @end
341
332
 
333
+ typedef struct {
334
+ void *window;
335
+ int width;
336
+ int height;
337
+ size_t byte_length;
338
+ BOOL busy;
339
+ } MetacoHandle;
340
+
341
+ static VALUE cMetacoWindow;
342
+
343
+ static void require_main_thread(void) {
344
+ if (!pthread_main_np()) rb_raise(rb_eThreadError, "Metaco must be called on the main thread");
345
+ }
346
+
347
+ static size_t pixel_byte_length(int width, int height) {
348
+ if (width <= 0 || height <= 0 || width > 16384 || height > 16384) {
349
+ rb_raise(rb_eArgError, "Width and height must be between 1 and 16384");
350
+ }
351
+ if ((size_t)width > SIZE_MAX / 4 / (size_t)height ||
352
+ (size_t)width * 4 * (size_t)height > LONG_MAX) {
353
+ rb_raise(rb_eArgError, "Pixel buffer size is too large");
354
+ }
355
+ return (size_t)width * (size_t)height * 4;
356
+ }
357
+
358
+ static void release_window(void *ptr) {
359
+ @autoreleasepool {
360
+ MetacoWindow *window = (__bridge_transfer MetacoWindow *)ptr;
361
+ window.delegate = nil;
362
+ [window close];
363
+ }
364
+ }
365
+
366
+ static void handle_free(void *ptr) {
367
+ MetacoHandle *handle = ptr;
368
+ if (handle->window) {
369
+ if (pthread_main_np()) release_window(handle->window);
370
+ else dispatch_async_f(dispatch_get_main_queue(), handle->window, release_window);
371
+ }
372
+ ruby_xfree(handle);
373
+ }
374
+
375
+ static size_t handle_size(const void *ptr) { return sizeof(MetacoHandle); }
376
+
377
+ static const rb_data_type_t handle_type = {
378
+ .wrap_struct_name = "Metaco::Window",
379
+ .function = {.dfree = handle_free, .dsize = handle_size},
380
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY
381
+ };
382
+
383
+ static MetacoHandle *get_handle(VALUE value, BOOL allow_closed) {
384
+ require_main_thread();
385
+ MetacoHandle *handle;
386
+ TypedData_Get_Struct(value, MetacoHandle, &handle_type, handle);
387
+ if (!allow_closed && !handle->window) rb_raise(rb_eArgError, "Window is closed");
388
+ if (handle->busy) rb_raise(rb_eRuntimeError, "Window is busy");
389
+ return handle;
390
+ }
391
+
342
392
  static VALUE cocoa_init(VALUE self) {
393
+ require_main_thread();
343
394
  @autoreleasepool {
344
395
  [NSApplication sharedApplication];
345
396
  [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
@@ -349,95 +400,171 @@ static VALUE cocoa_init(VALUE self) {
349
400
  }
350
401
 
351
402
  static VALUE cocoa_window_create(VALUE self, VALUE width, VALUE height, VALUE title) {
403
+ require_main_thread();
404
+ int w = NUM2INT(width);
405
+ int h = NUM2INT(height);
406
+ size_t length = pixel_byte_length(w, h);
407
+ Check_Type(title, T_STRING);
408
+ if (!NSApp) rb_raise(rb_eRuntimeError, "Call Metaco.init before creating a window");
409
+
410
+ MetacoHandle *handle;
411
+ VALUE result = TypedData_Make_Struct(cMetacoWindow, MetacoHandle, &handle_type, handle);
412
+ handle->width = w;
413
+ handle->height = h;
414
+ handle->byte_length = length;
415
+ BOOL valid_title;
352
416
  @autoreleasepool {
353
- int w = NUM2INT(width);
354
- int h = NUM2INT(height);
355
- NSString *t = [NSString stringWithUTF8String:StringValueCStr(title)];
356
-
357
- MetacoWindow *window = [[MetacoWindow alloc] initWithWidth:w height:h title:t];
358
- return ULONG2NUM((unsigned long)(__bridge_retained void *)window);
359
- }
360
- }
361
-
362
- static VALUE cocoa_window_destroy(VALUE self, VALUE window_ptr) {
363
- void *ptr = (void *)NUM2ULONG(window_ptr);
364
- if (!ptr) return Qnil;
365
-
366
- // Get window reference without transferring ownership yet
367
- MetacoWindow *window = (__bridge MetacoWindow *)ptr;
368
-
369
- // Synchronize GPU before cleanup (outside autoreleasepool)
370
- if (window && window.useMetal && window.metalView) {
371
- MetacoMetalView *metalView = window.metalView;
372
- if (metalView.commandQueue) {
373
- @try {
374
- id<MTLCommandBuffer> syncBuffer = [metalView.commandQueue commandBuffer];
375
- if (syncBuffer) {
376
- [syncBuffer commit];
377
- [syncBuffer waitUntilCompleted];
378
- }
379
- } @catch (NSException *e) {
380
- // Ignore - GPU may already be done
381
- }
417
+ NSString *text = [[NSString alloc] initWithBytes:RSTRING_PTR(title)
418
+ length:RSTRING_LEN(title)
419
+ encoding:NSUTF8StringEncoding];
420
+ valid_title = text != nil;
421
+ if (valid_title) {
422
+ MetacoWindow *window = [[MetacoWindow alloc] initWithWidth:w height:h title:text];
423
+ handle->window = (__bridge_retained void *)window;
382
424
  }
383
- // Mark that we've cleaned up the compute shader
384
- metalView.hasComputeShader = NO;
385
425
  }
426
+ RB_GC_GUARD(title);
427
+ if (!valid_title) rb_raise(rb_eArgError, "Title must contain valid UTF-8");
428
+ if (!handle->window) rb_raise(rb_eRuntimeError, "Failed to create window resources");
429
+ return result;
430
+ }
386
431
 
387
- // Close the window
388
- if (window) {
389
- [window close];
390
- }
391
-
392
- // Now transfer ownership to ARC - this will release the object
393
- // Do this outside autoreleasepool to avoid double-release issues
394
- (void)(__bridge_transfer id)ptr;
395
-
432
+ static VALUE cocoa_window_destroy(VALUE self, VALUE value) {
433
+ MetacoHandle *handle = get_handle(value, YES);
434
+ void *ptr = handle->window;
435
+ handle->window = NULL;
436
+ if (ptr) release_window(ptr);
437
+ RB_GC_GUARD(value);
396
438
  return Qnil;
397
439
  }
398
440
 
399
- static VALUE cocoa_set_pixels(VALUE self, VALUE window_ptr, VALUE buffer, VALUE width, VALUE height) {
400
- @autoreleasepool {
401
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
402
- int w = NUM2INT(width);
403
- int h = NUM2INT(height);
404
-
405
- Check_Type(buffer, T_STRING);
406
- const unsigned char *data = (const unsigned char *)RSTRING_PTR(buffer);
407
- long len = RSTRING_LEN(buffer);
408
-
409
- if (len < w * h * 4) {
410
- rb_raise(rb_eArgError, "Buffer too small");
411
- }
441
+ static VALUE cocoa_set_pixels(VALUE self, VALUE value, VALUE buffer, VALUE width, VALUE height) {
442
+ require_main_thread();
443
+ int w = NUM2INT(width);
444
+ int h = NUM2INT(height);
445
+ size_t length = pixel_byte_length(w, h);
446
+ Check_Type(buffer, T_STRING);
447
+ MetacoHandle *handle = get_handle(value, NO);
448
+ if (w != handle->width || h != handle->height) {
449
+ rb_raise(rb_eArgError, "Pixel dimensions must match the window");
450
+ }
451
+ if ((size_t)RSTRING_LEN(buffer) < length) rb_raise(rb_eArgError, "Buffer too small");
412
452
 
453
+ @autoreleasepool {
454
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
413
455
  if (window.useMetal) {
414
- [window.metalView updatePixels:data];
456
+ [window.metalView updatePixels:(const uint8_t *)RSTRING_PTR(buffer)];
415
457
  } else {
416
- unsigned char *bitmapData = [window.bitmapRep bitmapData];
417
- memcpy(bitmapData, data, w * h * 4);
458
+ memcpy(window.bitmapRep.bitmapData, RSTRING_PTR(buffer), handle->byte_length);
418
459
  }
419
460
  }
461
+ RB_GC_GUARD(buffer);
462
+ RB_GC_GUARD(value);
420
463
  return Qnil;
421
464
  }
422
465
 
423
- static VALUE cocoa_present(VALUE self, VALUE window_ptr) {
466
+ static void *submit_command(void *ptr) {
424
467
  @autoreleasepool {
425
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
468
+ id<MTLCommandBuffer> command = (__bridge id<MTLCommandBuffer>)ptr;
469
+ [command commit];
470
+ [command waitUntilCompleted];
471
+ }
472
+ return NULL;
473
+ }
426
474
 
427
- if (window.useMetal) {
428
- [window.metalView present];
475
+ static VALUE submit_without_gvl(VALUE ptr) {
476
+ rb_thread_call_without_gvl(submit_command, (void *)ptr, NULL, NULL);
477
+ return Qnil;
478
+ }
479
+
480
+ static void finish_command(id<MTLCommandBuffer> command, int *state, NSError **error) {
481
+ if (!command) return;
482
+ // ponytail: one frame in flight; add buffering only if synchronous presentation limits throughput.
483
+ // rb_protect also catches interrupts while the GVL is released/reacquired.
484
+ rb_protect(submit_without_gvl, (VALUE)(__bridge void *)command, state);
485
+ if (!*state && command.status != MTLCommandBufferStatusCompleted) {
486
+ if (command.error) *error = command.error;
487
+ else native_error(error, @"GPU command failed");
488
+ }
489
+ }
490
+
491
+ // Only call inside rb_protect (directly or through events_to_ruby).
492
+ static VALUE string_to_ruby(VALUE ptr) {
493
+ __unsafe_unretained NSString *text = (__bridge NSString *)(void *)ptr;
494
+ return rb_utf8_str_new(text.UTF8String, [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding]);
495
+ }
496
+
497
+ static VALUE cocoa_present_frame(VALUE value, BOOL compute) {
498
+ MetacoHandle *handle = get_handle(value, NO);
499
+ VALUE message = Qnil;
500
+ int state = 0;
501
+ handle->busy = YES;
502
+ @autoreleasepool {
503
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
504
+ NSError *error = nil;
505
+ if (compute && (!window.useMetal || !window.metalView.hasComputeShader)) {
506
+ native_error(&error, @"Compute shader not compiled");
507
+ } else if (window.useMetal) {
508
+ id<MTLTexture> texture = compute ? window.metalView.outputTexture : window.metalView.texture;
509
+ id<MTLCommandBuffer> command = [window.metalView presentTexture:texture error:&error];
510
+ finish_command(command, &state, &error);
429
511
  } else {
430
512
  [window.imageView setNeedsDisplay:YES];
431
513
  [window displayIfNeeded];
432
514
  }
515
+ if (error && !state) {
516
+ message = rb_protect(string_to_ruby, (VALUE)(__bridge void *)error.localizedDescription, &state);
517
+ }
433
518
  }
519
+ handle->busy = NO;
520
+ RB_GC_GUARD(value);
521
+ if (state) rb_jump_tag(state);
522
+ if (!NIL_P(message)) rb_exc_raise(rb_exc_new_str(rb_eRuntimeError, message));
434
523
  return Qnil;
435
524
  }
436
525
 
437
- static VALUE cocoa_poll_events(VALUE self, VALUE window_ptr) {
438
- @autoreleasepool {
439
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
526
+ static VALUE cocoa_present(VALUE self, VALUE value) { return cocoa_present_frame(value, NO); }
527
+ static VALUE cocoa_present_compute(VALUE self, VALUE value) { return cocoa_present_frame(value, YES); }
528
+
529
+ // Called only via rb_protect: no owning Objective-C locals or autorelease pools
530
+ // may live in a frame that Ruby's longjmp can bypass.
531
+ static VALUE events_to_ruby(VALUE ptr) {
532
+ __unsafe_unretained NSArray *pending = (__bridge NSArray *)(void *)ptr;
533
+ VALUE events = rb_ary_new();
534
+ for (NSUInteger i = 0; i < pending.count; i++) {
535
+ __unsafe_unretained NSDictionary *dict = pending[i];
536
+ VALUE hash = rb_hash_new();
537
+ rb_hash_aset(hash, ID2SYM(rb_intern("type")),
538
+ ID2SYM(rb_intern([dict[@"type"] UTF8String])));
539
+ if (dict[@"key"]) {
540
+ rb_hash_aset(hash, ID2SYM(rb_intern("key")), INT2NUM([dict[@"key"] intValue]));
541
+ }
542
+ if (dict[@"char"]) {
543
+ __unsafe_unretained NSString *characters = dict[@"char"];
544
+ rb_hash_aset(hash, ID2SYM(rb_intern("char")),
545
+ string_to_ruby((VALUE)(__bridge void *)characters));
546
+ }
547
+ if (dict[@"x"]) {
548
+ rb_hash_aset(hash, ID2SYM(rb_intern("x")), DBL2NUM([dict[@"x"] doubleValue]));
549
+ }
550
+ if (dict[@"y"]) {
551
+ rb_hash_aset(hash, ID2SYM(rb_intern("y")), DBL2NUM([dict[@"y"] doubleValue]));
552
+ }
553
+ if (dict[@"button"]) {
554
+ rb_hash_aset(hash, ID2SYM(rb_intern("button")), INT2NUM([dict[@"button"] intValue]));
555
+ }
556
+ rb_ary_push(events, hash);
557
+ }
558
+ return events;
559
+ }
440
560
 
561
+ static VALUE cocoa_poll_events(VALUE self, VALUE value) {
562
+ MetacoHandle *handle = get_handle(value, NO);
563
+ int state = 0;
564
+ VALUE events;
565
+ handle->busy = YES;
566
+ @autoreleasepool {
567
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
441
568
  NSEvent *event;
442
569
  while ((event = [NSApp nextEventMatchingMask:NSEventMaskAny
443
570
  untilDate:nil
@@ -445,108 +572,117 @@ static VALUE cocoa_poll_events(VALUE self, VALUE window_ptr) {
445
572
  dequeue:YES])) {
446
573
  [NSApp sendEvent:event];
447
574
  }
448
-
449
575
  [[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
450
576
  beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.001]];
451
577
  [NSApp updateWindows];
452
-
453
- VALUE events = rb_ary_new();
454
- for (NSDictionary *dict in window.pendingEvents) {
455
- VALUE hash = rb_hash_new();
456
-
457
- NSString *type = dict[@"type"];
458
- rb_hash_aset(hash, ID2SYM(rb_intern("type")),
459
- ID2SYM(rb_intern([type UTF8String])));
460
-
461
- if (dict[@"key"]) {
462
- rb_hash_aset(hash, ID2SYM(rb_intern("key")), INT2NUM([dict[@"key"] intValue]));
463
- }
464
- if (dict[@"char"]) {
465
- rb_hash_aset(hash, ID2SYM(rb_intern("char")),
466
- rb_str_new_cstr([dict[@"char"] UTF8String]));
467
- }
468
- if (dict[@"x"]) {
469
- rb_hash_aset(hash, ID2SYM(rb_intern("x")), DBL2NUM([dict[@"x"] doubleValue]));
470
- }
471
- if (dict[@"y"]) {
472
- rb_hash_aset(hash, ID2SYM(rb_intern("y")), DBL2NUM([dict[@"y"] doubleValue]));
473
- }
474
- if (dict[@"button"]) {
475
- rb_hash_aset(hash, ID2SYM(rb_intern("button")), INT2NUM([dict[@"button"] intValue]));
476
- }
477
-
478
- rb_ary_push(events, hash);
479
- }
480
-
481
- [window.pendingEvents removeAllObjects];
482
- return events;
578
+ events = rb_protect(events_to_ruby, (VALUE)(__bridge void *)window.pendingEvents, &state);
579
+ if (!state) [window.pendingEvents removeAllObjects];
483
580
  }
581
+ handle->busy = NO;
582
+ RB_GC_GUARD(value);
583
+ if (state) rb_jump_tag(state);
584
+ return events;
484
585
  }
485
586
 
486
- static VALUE cocoa_should_close(VALUE self, VALUE window_ptr) {
487
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
488
- return window.shouldClose ? Qtrue : Qfalse;
587
+ static VALUE cocoa_should_close(VALUE self, VALUE value) {
588
+ MetacoHandle *handle = get_handle(value, NO);
589
+ BOOL result;
590
+ @autoreleasepool {
591
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
592
+ result = window.shouldClose;
593
+ }
594
+ RB_GC_GUARD(value);
595
+ return result ? Qtrue : Qfalse;
489
596
  }
490
597
 
491
- // ========== Compute Shader Bridge Functions ==========
492
-
493
- static VALUE cocoa_metal_compute_available(VALUE self, VALUE window_ptr) {
494
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
495
- return (window.useMetal && window.metalView.device) ? Qtrue : Qfalse;
598
+ static VALUE cocoa_metal_compute_available(VALUE self, VALUE value) {
599
+ MetacoHandle *handle = get_handle(value, NO);
600
+ BOOL result;
601
+ @autoreleasepool {
602
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
603
+ result = window.useMetal;
604
+ }
605
+ RB_GC_GUARD(value);
606
+ return result ? Qtrue : Qfalse;
496
607
  }
497
608
 
498
- static VALUE cocoa_compile_compute_shader(VALUE self, VALUE window_ptr, VALUE msl_source) {
609
+ static VALUE cocoa_compile_compute_shader(VALUE self, VALUE value, VALUE msl_source) {
610
+ Check_Type(msl_source, T_STRING);
611
+ MetacoHandle *handle = get_handle(value, NO);
612
+ VALUE message = Qnil;
613
+ int state = 0;
614
+ handle->busy = YES;
499
615
  @autoreleasepool {
500
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
616
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
617
+ NSString *source = [[NSString alloc] initWithBytes:RSTRING_PTR(msl_source)
618
+ length:RSTRING_LEN(msl_source)
619
+ encoding:NSUTF8StringEncoding];
620
+ NSError *error = nil;
501
621
  if (!window.useMetal) {
502
- rb_raise(rb_eRuntimeError, "Metal not available");
622
+ native_error(&error, @"Metal not available");
623
+ } else if (!source) {
624
+ native_error(&error, @"Shader source must contain valid UTF-8");
625
+ } else if (![window.metalView compileComputeShader:source error:&error] && !error) {
626
+ native_error(&error, @"Failed to compile shader");
503
627
  }
504
-
505
- Check_Type(msl_source, T_STRING);
506
- NSString *source = [NSString stringWithUTF8String:StringValueCStr(msl_source)];
507
- NSError *error = nil;
508
-
509
- if (![window.metalView compileComputeShader:source error:&error]) {
510
- rb_raise(rb_eRuntimeError, "Failed to compile shader: %s",
511
- [[error localizedDescription] UTF8String]);
628
+ if (error) {
629
+ message = rb_protect(string_to_ruby, (VALUE)(__bridge void *)error.localizedDescription, &state);
512
630
  }
513
631
  }
632
+ handle->busy = NO;
633
+ RB_GC_GUARD(msl_source);
634
+ RB_GC_GUARD(value);
635
+ if (state) rb_jump_tag(state);
636
+ if (!NIL_P(message)) rb_exc_raise(rb_exc_new_str(rb_eRuntimeError, message));
514
637
  return Qtrue;
515
638
  }
516
639
 
517
- static VALUE cocoa_dispatch_compute(VALUE self, VALUE window_ptr, VALUE uniform_data) {
640
+ static VALUE cocoa_dispatch_compute(VALUE self, VALUE value, VALUE uniform_data) {
641
+ Check_Type(uniform_data, T_STRING);
642
+ if (RSTRING_LEN(uniform_data) > 256) rb_raise(rb_eArgError, "Uniform data must be at most 256 bytes");
643
+ MetacoHandle *handle = get_handle(value, NO);
644
+ VALUE message = Qnil;
645
+ int state = 0;
646
+ handle->busy = YES;
518
647
  @autoreleasepool {
519
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
648
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
649
+ NSError *error = nil;
520
650
  if (!window.useMetal || !window.metalView.hasComputeShader) {
521
- rb_raise(rb_eRuntimeError, "Compute shader not compiled");
651
+ native_error(&error, @"Compute shader not compiled");
652
+ } else {
653
+ id<MTLCommandBuffer> command = [window.metalView dispatchComputeWithUniforms:RSTRING_PTR(uniform_data)
654
+ length:RSTRING_LEN(uniform_data)
655
+ error:&error];
656
+ finish_command(command, &state, &error);
657
+ }
658
+ if (error && !state) {
659
+ message = rb_protect(string_to_ruby, (VALUE)(__bridge void *)error.localizedDescription, &state);
522
660
  }
523
-
524
- Check_Type(uniform_data, T_STRING);
525
- const void *data = RSTRING_PTR(uniform_data);
526
- long length = RSTRING_LEN(uniform_data);
527
-
528
- [window.metalView dispatchComputeWithUniforms:data length:length];
529
661
  }
662
+ handle->busy = NO;
663
+ RB_GC_GUARD(uniform_data);
664
+ RB_GC_GUARD(value);
665
+ if (state) rb_jump_tag(state);
666
+ if (!NIL_P(message)) rb_exc_raise(rb_exc_new_str(rb_eRuntimeError, message));
530
667
  return Qnil;
531
668
  }
532
669
 
533
- static VALUE cocoa_present_compute(VALUE self, VALUE window_ptr) {
670
+ static VALUE cocoa_has_compute_shader(VALUE self, VALUE value) {
671
+ MetacoHandle *handle = get_handle(value, NO);
672
+ BOOL result;
534
673
  @autoreleasepool {
535
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
536
- if (window.useMetal && window.metalView.hasComputeShader) {
537
- [window.metalView presentCompute];
538
- }
674
+ MetacoWindow *window = (__bridge MetacoWindow *)handle->window;
675
+ result = window.useMetal && window.metalView.hasComputeShader;
539
676
  }
540
- return Qnil;
541
- }
542
-
543
- static VALUE cocoa_has_compute_shader(VALUE self, VALUE window_ptr) {
544
- MetacoWindow *window = (__bridge MetacoWindow *)(void *)NUM2ULONG(window_ptr);
545
- return (window.useMetal && window.metalView.hasComputeShader) ? Qtrue : Qfalse;
677
+ RB_GC_GUARD(value);
678
+ return result ? Qtrue : Qfalse;
546
679
  }
547
680
 
548
681
  void Init_metaco(void) {
549
682
  VALUE mMetaco = rb_define_module("Metaco");
683
+ cMetacoWindow = rb_define_class_under(mMetaco, "Window", rb_cObject);
684
+ rb_global_variable(&cMetacoWindow);
685
+ rb_undef_alloc_func(cMetacoWindow);
550
686
 
551
687
  rb_define_module_function(mMetaco, "init", cocoa_init, 0);
552
688
  rb_define_module_function(mMetaco, "window_create", cocoa_window_create, 3);
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Metaco
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/metaco.rb CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  require_relative "metaco/version"
4
4
 
5
- begin
5
+ if RUBY_PLATFORM.include?("darwin")
6
6
  require "metaco/metaco"
7
- rescue LoadError
8
- # Extension not compiled - this is expected on non-macOS platforms
7
+ else
9
8
  module Metaco
10
9
  def self.init
11
10
  raise LoadError, "Metaco is only available on macOS"
@@ -34,5 +33,25 @@ rescue LoadError
34
33
  def self.should_close?(_handle)
35
34
  raise LoadError, "Metaco is only available on macOS"
36
35
  end
36
+
37
+ def self.metal_compute_available?(_handle)
38
+ false
39
+ end
40
+
41
+ def self.has_compute_shader?(_handle)
42
+ false
43
+ end
44
+
45
+ def self.compile_compute_shader(_handle, _source)
46
+ raise LoadError, "Metaco is only available on macOS"
47
+ end
48
+
49
+ def self.dispatch_compute(_handle, _uniforms)
50
+ raise LoadError, "Metaco is only available on macOS"
51
+ end
52
+
53
+ def self.present_compute(_handle)
54
+ raise LoadError, "Metaco is only available on macOS"
55
+ end
37
56
  end
38
57
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metaco
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
@@ -26,13 +26,12 @@ files:
26
26
  - ext/metaco/metaco.m
27
27
  - lib/metaco.rb
28
28
  - lib/metaco/version.rb
29
- - metaco.gemspec
30
- homepage: https://github.com/ydah/metaco
29
+ homepage: https://github.com/rbgfx/metaco
31
30
  licenses:
32
31
  - MIT
33
32
  metadata:
34
- source_code_uri: https://github.com/ydah/metaco
35
- changelog_uri: https://github.com/ydah/metaco/blob/main/CHANGELOG.md
33
+ source_code_uri: https://github.com/rbgfx/metaco
34
+ changelog_uri: https://github.com/rbgfx/metaco/blob/main/CHANGELOG.md
36
35
  rdoc_options: []
37
36
  require_paths:
38
37
  - lib
@@ -47,7 +46,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
47
46
  - !ruby/object:Gem::Version
48
47
  version: '0'
49
48
  requirements: []
50
- rubygems_version: 4.0.3
49
+ rubygems_version: 4.0.19
51
50
  specification_version: 4
52
51
  summary: Native macOS Cocoa/Metal bridge for Ruby graphics applications
53
52
  test_files: []
data/metaco.gemspec DELETED
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/metaco/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "metaco"
7
- spec.version = Metaco::VERSION
8
- spec.authors = ["Yudai Takada"]
9
- spec.email = ["t.yudai92@gmail.com"]
10
-
11
- spec.summary = "Native macOS Cocoa/Metal bridge for Ruby graphics applications"
12
- spec.description = "A Ruby C extension providing native macOS window management and Metal GPU acceleration for graphics applications."
13
- spec.homepage = "https://github.com/ydah/metaco"
14
- spec.license = "MIT"
15
- spec.required_ruby_version = ">= 3.0.0"
16
-
17
- spec.metadata["source_code_uri"] = spec.homepage
18
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
19
-
20
- spec.files = Dir.chdir(__dir__) do
21
- `git ls-files -z`.split("\x0").reject do |f|
22
- (File.expand_path(f) == __FILE__) ||
23
- f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
24
- end
25
- end
26
- spec.require_paths = ["lib"]
27
- spec.extensions = ["ext/metaco/extconf.rb"]
28
- end