Skip to content

Commit e2b06b2

Browse files
committed
WIP implement own context classes
1 parent 8868e1e commit e2b06b2

File tree

11 files changed

+1144
-133
lines changed

11 files changed

+1144
-133
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*
2+
3+
clang -dynamiclib -fobjc-arc \
4+
-framework Foundation -framework Metal -framework IOSurface \
5+
-arch x86_64 -arch arm64 \
6+
-mmacosx-version-min=10.13 \
7+
-o libMetalIOSurfaceHelper.dylib MetalIOSurfaceHelper.m
8+
9+
*/
10+
#import <Foundation/Foundation.h>
11+
#import <Metal/Metal.h>
12+
#import <IOSurface/IOSurface.h>
13+
14+
@interface MetalIOSurfaceHelper : NSObject
15+
@property (nonatomic, readonly) id<MTLDevice> device;
16+
@property (nonatomic, readonly) id<MTLTexture> texture;
17+
18+
- (instancetype)initWithWidth:(NSUInteger)width
19+
height:(NSUInteger)height;
20+
21+
- (void *)baseAddress;
22+
- (NSUInteger)bytesPerRow;
23+
@end
24+
25+
26+
@implementation MetalIOSurfaceHelper {
27+
IOSurfaceRef _surf;
28+
}
29+
30+
- (instancetype)initWithWidth:(NSUInteger)width
31+
height:(NSUInteger)height
32+
{
33+
if ((self = [super init])) {
34+
// Create Metal device
35+
_device = MTLCreateSystemDefaultDevice();
36+
if (!_device) {
37+
NSLog(@"❌ Failed to create Metal device");
38+
return nil;
39+
}
40+
41+
// Create IOSurface properties
42+
NSDictionary *props = @{
43+
(id)kIOSurfaceWidth: @(width),
44+
(id)kIOSurfaceHeight: @(height),
45+
(id)kIOSurfaceBytesPerElement: @(4),
46+
(id)kIOSurfacePixelFormat: @(0x42475241) // 'BGRA'
47+
};
48+
49+
_surf = IOSurfaceCreate((__bridge CFDictionaryRef)props);
50+
if (!_surf) {
51+
NSLog(@"❌ Failed to create IOSurface");
52+
return nil;
53+
}
54+
55+
// Create texture from IOSurface
56+
MTLTextureDescriptor *desc =
57+
[MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatBGRA8Unorm
58+
width:width
59+
height:height
60+
mipmapped:NO];
61+
desc.storageMode = MTLStorageModeShared;
62+
63+
_texture = [_device newTextureWithDescriptor:desc iosurface:_surf plane:0];
64+
if (!_texture) {
65+
NSLog(@"❌ Failed to create MTLTexture from IOSurface");
66+
CFRelease(_surf);
67+
return nil;
68+
}
69+
}
70+
return self;
71+
}
72+
73+
- (void *)baseAddress {
74+
return IOSurfaceGetBaseAddress(_surf);
75+
}
76+
77+
- (NSUInteger)bytesPerRow {
78+
return IOSurfaceGetBytesPerRow(_surf);
79+
}
80+
81+
- (void)dealloc {
82+
if (_surf) {
83+
CFRelease(_surf);
84+
_surf = NULL;
85+
}
86+
}
87+
88+
@end

rendercanvas/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,8 @@
88
from . import _coreutils
99
from ._enums import CursorShape, EventType, UpdateMode
1010
from .base import BaseRenderCanvas, BaseLoop
11+
from . import contexts
12+
from . import utils
13+
1114

1215
__all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType", "UpdateMode"]

rendercanvas/_native_osx.py

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
"""
2+
3+
This uses rubicon to load objc classes, mainly for Cocoa (MacOS's
4+
windowing API). For rendering to bitmap we follow the super-fast
5+
approach of creating an IOSurface that is wrapped in a Metal texture.
6+
On Apple silicon, the memory for that texture is in RAM, so we can write
7+
directly to the texture, no copies. This approach is used by e.g. video
8+
viewers.
9+
10+
However, because Python (via Rubicon) cannot pass or create pure C-level
11+
IOSurfaceRef pointers, which are required by Metal’s
12+
newTextureWithDescriptor:iosurface:plane; Rubicon can only work with
13+
actual Objective-C objects.
14+
15+
Therefore this code relies on a mirco objc libary that is shipped along
16+
in rendercanvas. This dylib handles the C-level IOSurface creation and
17+
wraps it in a proper MTLTexture that Python can safely use.
18+
"""
19+
20+
# ruff: noqa - for now
21+
22+
import sys
23+
import time
24+
import ctypes
25+
26+
import numpy as np
27+
from rubicon.objc import ObjCClass, objc_method, ObjCInstance
28+
29+
30+
NSApplication = ObjCClass("NSApplication")
31+
NSWindow = ObjCClass("NSWindow")
32+
NSObject = ObjCClass("NSObject")
33+
34+
35+
# Application and window
36+
app = NSApplication.sharedApplication
37+
38+
39+
SHADER = """
40+
#include <metal_stdlib>
41+
using namespace metal;
42+
43+
struct VertexOut {
44+
float4 position [[position]];
45+
float2 texcoord;
46+
};
47+
48+
vertex VertexOut vertex_main(uint vertexID [[vertex_id]]) {
49+
float2 pos[3] = {
50+
float2(-1.0, -1.0),
51+
float2( 3.0, -1.0),
52+
float2(-1.0, 3.0)
53+
};
54+
VertexOut out;
55+
out.position = float4(pos[vertexID], 0.0, 1.0);
56+
out.texcoord = (pos[vertexID] + 1.0) * 0.5;
57+
return out;
58+
}
59+
60+
fragment float4 fragment_main(VertexOut in [[stage_in]],
61+
texture2d<float> tex [[texture(0)]],
62+
sampler samp [[sampler(0)]]) {
63+
constexpr sampler linearSampler(address::clamp_to_edge, filter::linear);
64+
float4 color = tex.sample(linearSampler, in.texcoord);
65+
return color;
66+
}
67+
"""
68+
69+
70+
class MetalRenderer(NSObject):
71+
@objc_method
72+
def initWithDevice_(self, device): # -> ctypes.c_void_p:
73+
self.init()
74+
# self = ObjCInstance(send_message(self, "init"))
75+
if self is None:
76+
return None
77+
self.device = device
78+
self.queue = device.newCommandQueue()
79+
80+
# self.texture = win._texture
81+
82+
# --- Metal shader code ---
83+
84+
options = {}
85+
error_placeholder = None # ctypes.c_void_p()
86+
library = device.newLibraryWithSource_options_error_(
87+
SHADER, None, error_placeholder
88+
)
89+
if not library:
90+
print("Shader compile failed:", error_placeholder)
91+
return self
92+
93+
vertex_func = library.newFunctionWithName_("vertex_main")
94+
frag_func = library.newFunctionWithName_("fragment_main")
95+
96+
desc = ObjCClass("MTLRenderPipelineDescriptor").alloc().init()
97+
desc.vertexFunction = vertex_func
98+
desc.fragmentFunction = frag_func
99+
desc.colorAttachments.objectAtIndexedSubscript_(
100+
0
101+
).pixelFormat = 80 # BGRA8Unorm
102+
103+
self.pipeline = device.newRenderPipelineStateWithDescriptor_error_(
104+
desc, error_placeholder
105+
)
106+
if not self.pipeline:
107+
print("Pipeline creation failed:", error_placeholder)
108+
return self
109+
110+
@objc_method
111+
def setTexture_(self, texture):
112+
self.texture = texture
113+
114+
@objc_method
115+
def drawInMTKView_(self, view):
116+
drawable = view.currentDrawable
117+
if drawable is None:
118+
return
119+
120+
passdesc = ObjCClass("MTLRenderPassDescriptor").renderPassDescriptor()
121+
passdesc.colorAttachments.objectAtIndexedSubscript_(
122+
0
123+
).texture = drawable.texture
124+
passdesc.colorAttachments.objectAtIndexedSubscript_(0).loadAction = 2 # Clear
125+
passdesc.colorAttachments.objectAtIndexedSubscript_(0).storeAction = 1 # Store
126+
passdesc.colorAttachments.objectAtIndexedSubscript_(
127+
0
128+
).clearColor = view.clearColor
129+
130+
cmd_buf = self.queue.commandBuffer()
131+
enc = cmd_buf.renderCommandEncoderWithDescriptor_(passdesc)
132+
133+
enc.setRenderPipelineState_(self.pipeline)
134+
enc.setFragmentTexture_atIndex_(self.texture, 0)
135+
136+
enc.setRenderPipelineState_(self.pipeline)
137+
enc.drawPrimitives_vertexStart_vertexCount_(3, 0, 3)
138+
enc.endEncoding()
139+
cmd_buf.presentDrawable_(drawable)
140+
cmd_buf.commit()
141+
142+
@objc_method
143+
def mtkView_drawableSizeWillChange_(self, view, newSize):
144+
# Update if needed
145+
print("resize", newSize)
146+
pass
147+
148+
149+
class Window:
150+
def __init__(self):
151+
self._width = 512
152+
self._height = 512
153+
154+
rect = (100, 100), (384, 216)
155+
156+
# Define window style
157+
NSWindowStyleMaskTitled = 1 << 0
158+
NSBackingStoreBuffered = 2
159+
NSTitledWindowMask = 1 << 0
160+
NSClosableWindowMask = 1 << 1
161+
NSMiniaturizableWindowMask = 1 << 2
162+
NSResizableWindowMask = 1 << 3
163+
style_mask = (
164+
NSTitledWindowMask
165+
| NSClosableWindowMask
166+
| NSMiniaturizableWindowMask
167+
| NSResizableWindowMask
168+
)
169+
170+
self._window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
171+
rect, style_mask, NSBackingStoreBuffered, False
172+
)
173+
self._window.setTitle_("Rendercanvas cocoa demo")
174+
self._window.makeKeyAndOrderFront_(None)
175+
176+
self._content_view = self._window.contentView
177+
178+
self._init_for_bitmap_present()
179+
180+
def _init_for_bitmap_present(self):
181+
# Load our helper dylib to make its objc class available to rubicon.
182+
ctypes.CDLL("./libMetalIOSurfaceHelper.dylib")
183+
184+
# Init our little helper helper
185+
MetalIOSurfaceHelper = ObjCClass("MetalIOSurfaceHelper")
186+
self._helper = MetalIOSurfaceHelper.alloc().initWithWidth_height_(
187+
self._width, self._height
188+
)
189+
self._texture = self._helper.texture
190+
self._device = self._helper.device
191+
192+
# Access CPU memory
193+
base_addr = self._helper.baseAddress()
194+
bytes_per_row = self._helper.bytesPerRow()
195+
196+
# Map array onto the shared memory
197+
total_bytes = bytes_per_row * self._height
198+
array_type = ctypes.c_uint8 * total_bytes
199+
pixel_buf = array_type.from_address(base_addr.value)
200+
self._texture_array = np.frombuffer(
201+
pixel_buf, dtype=np.uint8, count=total_bytes
202+
).reshape(self._height, self._width, 4)
203+
204+
# Create MTKView
205+
MTKView = ObjCClass("MTKView")
206+
mtk_view = MTKView.alloc().initWithFrame_device_(
207+
self._content_view.bounds, self._device
208+
)
209+
# Ensure we can write into the view's texture (not framebuffer-only) if we want to upload into it
210+
try:
211+
mtk_view.setFramebufferOnly_(False)
212+
except Exception:
213+
pass # Not all setups require this call; ignore if not present
214+
215+
# TODO: use RGBA
216+
# TODO: support yuv420p or something
217+
# Choose pixel format. We'll assume BGRA8Unorm for Metal.
218+
mtk_view.setColorPixelFormat_(80) # MTLPixelFormatBGRA8Unorm
219+
220+
self._window.setContentView_(mtk_view)
221+
self._window.makeKeyAndOrderFront_(None) # todo: what does this do?
222+
223+
# Instantiate the renderer and set as delegate
224+
# renderer = MetalRenderer.alloc().init()
225+
renderer = MetalRenderer.alloc().initWithDevice_(self._device)
226+
renderer.setTexture(self._texture)
227+
mtk_view.setDelegate_(renderer)
228+
229+
230+
# Example: fill the buffer with a test pattern
231+
def fill_test_pattern(frame_index=0):
232+
# simple moving gradient to prove updates work
233+
t = frame_index % 256
234+
# pixels_np[:, :, 0] = (np.arange(WIDTH, dtype=np.uint8) + t) & 0xFF # R-ish
235+
# pixels_np[:, :, 1] = (
236+
# np.arange(HEIGHT, dtype=np.uint8).reshape(HEIGHT, 1) + t
237+
# ) & 0xFF # G-ish
238+
# pixels_np[:, :, 2] = t # B constant
239+
# pixels_np[:, :, 3] = 255 # full alpha
240+
241+
pixels_np = win._texture_array
242+
pixels_np[:, :, 3] = 255
243+
pixels_np[:t, :, :3] = 0
244+
pixels_np[t:, :, :3] = 255
245+
246+
247+
def update_texture(frame_index):
248+
fill_test_pattern(frame_index)
249+
250+
# mtk_view.setNeedsDisplay_(True)
251+
return
252+
pixel_buf = win._texture_array
253+
254+
bytes_per_row = win._width * 4
255+
region = ((0, 0, 0), (win._width, win._height, 1))
256+
renderer.texture.replaceRegion_mipmapLevel_withBytes_bytesPerRow_(
257+
region, 0, pixel_buf, bytes_per_row
258+
)
259+
260+
261+
win = Window()
262+
263+
frame_index = 0
264+
while True:
265+
frame_index += 1
266+
# Drain events (non-blocking)
267+
event = app.nextEventMatchingMask_untilDate_inMode_dequeue_(
268+
0xFFFFFFFFFFFFFFFF, # all events
269+
None, # don't wait
270+
"kCFRunLoopDefaultMode",
271+
True,
272+
)
273+
if event:
274+
app.sendEvent_(event)
275+
276+
update_texture(frame_index)
277+
278+
app.updateWindows()
279+
280+
# your own update / render logic here
281+
# (Metal drawInMTKView_ will get called by MTKView’s internal timer)
282+
time.sleep(1 / 120) # e.g. 120 Hz pacing

0 commit comments

Comments
 (0)