Quellcode durchsuchen

Added curved window mode on visionOS 26 (#15298)

Sam Lantinga vor 1 Tag
Ursprung
Commit
5cf16e4522

+ 68 - 24
Xcode/SDL/SDL.xcodeproj/project.pbxproj

@@ -50,13 +50,14 @@
 		0000AEB9AE90228CA2D60000 /* SDL_asyncio.c in Sources */ = {isa = PBXBuildFile; fileRef = 00003928A612EC33D42C0000 /* SDL_asyncio.c */; };
 		0000D5B526B85DE7AB1C0000 /* SDL_cocoapen.m in Sources */ = {isa = PBXBuildFile; fileRef = 0000CCA310B73A7B59910000 /* SDL_cocoapen.m */; };
 		007317A40858DECD00B2BC32 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0073179D0858DECD00B2BC32 /* Cocoa.framework */; platformFilters = (macos, ); };
-		007317A60858DECD00B2BC32 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0073179F0858DECD00B2BC32 /* IOKit.framework */; platformFilters = (ios, maccatalyst, macos, ); };
+		007317A60858DECD00B2BC32 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0073179F0858DECD00B2BC32 /* IOKit.framework */; platformFilters = (ios, maccatalyst, macos, xros, ); };
 		00CFA89D106B4BA100758660 /* ForceFeedback.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00CFA89C106B4BA100758660 /* ForceFeedback.framework */; platformFilters = (macos, ); };
-		00D0D08410675DD9004B05EF /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00D0D08310675DD9004B05EF /* CoreFoundation.framework */; platformFilters = (ios, maccatalyst, macos, tvos, ); settings = {ATTRIBUTES = (Required, ); }; };
+		00D0D08410675DD9004B05EF /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 00D0D08310675DD9004B05EF /* CoreFoundation.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); settings = {ATTRIBUTES = (Required, ); }; };
 		00D0D0D810675E46004B05EF /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 007317C10858E15000B2BC32 /* Carbon.framework */; platformFilters = (macos, ); };
 		02D6A1C228A84B8F00A7F002 /* SDL_hidapi_sinput.c in Sources */ = {isa = PBXBuildFile; fileRef = 02D6A1C128A84B8F00A7F001 /* SDL_hidapi_sinput.c */; };
 		1485C3312BBA4AF30063985B /* UniformTypeIdentifiers.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1485C32F2BBA4A0C0063985B /* UniformTypeIdentifiers.framework */; platformFilters = (maccatalyst, macos, ); settings = {ATTRIBUTES = (Weak, ); }; };
-		557D0CFA254586CA003913E3 /* CoreHaptics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F37DC5F225350EBC0002E6F7 /* CoreHaptics.framework */; platformFilters = (ios, maccatalyst, macos, tvos, ); settings = {ATTRIBUTES = (Weak, ); }; };
+		3AFD09EA2F9766BA00208BA9 /* SDL_CurvedUIShader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AFD09E92F9766BA00208BA9 /* SDL_CurvedUIShader.swift */; platformFilters = (xros, ); };
+		557D0CFA254586CA003913E3 /* CoreHaptics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F37DC5F225350EBC0002E6F7 /* CoreHaptics.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); settings = {ATTRIBUTES = (Weak, ); }; };
 		557D0CFB254586D7003913E3 /* GameController.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A75FDABD23E28B6200529352 /* GameController.framework */; settings = {ATTRIBUTES = (Required, ); }; };
 		5616CA4C252BB2A6005D5928 /* SDL_url.c in Sources */ = {isa = PBXBuildFile; fileRef = 5616CA49252BB2A5005D5928 /* SDL_url.c */; };
 		5616CA4D252BB2A6005D5928 /* SDL_sysurl.h in Headers */ = {isa = PBXBuildFile; fileRef = 5616CA4A252BB2A6005D5928 /* SDL_sysurl.h */; };
@@ -82,8 +83,8 @@
 		A1626A522617008D003F1973 /* SDL_triangle.h in Headers */ = {isa = PBXBuildFile; fileRef = A1626A512617008C003F1973 /* SDL_triangle.h */; };
 		A1BB8B6327F6CF330057CFA8 /* SDL_list.c in Sources */ = {isa = PBXBuildFile; fileRef = A1BB8B6127F6CF320057CFA8 /* SDL_list.c */; };
 		A1BB8B6C27F6CF330057CFA8 /* SDL_list.h in Headers */ = {isa = PBXBuildFile; fileRef = A1BB8B6227F6CF330057CFA8 /* SDL_list.h */; };
-		A7381E961D8B69D600B177DD /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7381E951D8B69D600B177DD /* CoreAudio.framework */; platformFilters = (ios, maccatalyst, macos, tvos, ); settings = {ATTRIBUTES = (Required, ); }; };
-		A7381E971D8B6A0300B177DD /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7381E931D8B69C300B177DD /* AudioToolbox.framework */; platformFilters = (ios, maccatalyst, macos, tvos, ); };
+		A7381E961D8B69D600B177DD /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7381E951D8B69D600B177DD /* CoreAudio.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); settings = {ATTRIBUTES = (Required, ); }; };
+		A7381E971D8B6A0300B177DD /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7381E931D8B69C300B177DD /* AudioToolbox.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); };
 		A75FDB5823E39E6100529352 /* hidapi.h in Headers */ = {isa = PBXBuildFile; fileRef = A75FDB5723E39E6100529352 /* hidapi.h */; };
 		A75FDBC523EA380300529352 /* SDL_hidapi_rumble.h in Headers */ = {isa = PBXBuildFile; fileRef = A75FDBC323EA380300529352 /* SDL_hidapi_rumble.h */; };
 		A75FDBCE23EA380300529352 /* SDL_hidapi_rumble.c in Sources */ = {isa = PBXBuildFile; fileRef = A75FDBC423EA380300529352 /* SDL_hidapi_rumble.c */; };
@@ -368,8 +369,6 @@
 		E4F257972C81903800FCEAFC /* SDL_sysgpu.h in Headers */ = {isa = PBXBuildFile; fileRef = E4F257862C81903800FCEAFC /* SDL_sysgpu.h */; };
 		E4F257982C81903800FCEAFC /* SDL_gpu_openxr.c in Sources */ = {isa = PBXBuildFile; fileRef = E4F257882C81903800FCEAFC /* SDL_gpu_openxr.c */; };
 		E4F257992C81903800FCEAFC /* SDL_openxrdyn.c in Sources */ = {isa = PBXBuildFile; fileRef = E4F257892C81903800FCEAFC /* SDL_openxrdyn.c */; };
-		E4F2579A2C81903800FCEAFC /* SDL_gpu_openxr_c.h in Headers */ = {isa = PBXBuildFile; fileRef = E4F2578A2C81903800FCEAFC /* SDL_gpu_openxr_c.h */; };
-		E4F2579B2C81903800FCEAFC /* SDL_openxr_internal.h in Headers */ = {isa = PBXBuildFile; fileRef = E4F2578C2C81903800FCEAFC /* SDL_openxr_internal.h */; };
 		E4F7981A2AD8D84800669F54 /* SDL_core_unsupported.c in Sources */ = {isa = PBXBuildFile; fileRef = E4F798192AD8D84800669F54 /* SDL_core_unsupported.c */; };
 		E4F7981C2AD8D85500669F54 /* SDL_dynapi_unsupported.h in Headers */ = {isa = PBXBuildFile; fileRef = E4F7981B2AD8D85500669F54 /* SDL_dynapi_unsupported.h */; };
 		E4F7981E2AD8D86A00669F54 /* SDL_render_unsupported.c in Sources */ = {isa = PBXBuildFile; fileRef = E4F7981D2AD8D86A00669F54 /* SDL_render_unsupported.c */; };
@@ -434,6 +433,13 @@
 		F3990E062A788303000D8759 /* SDL_hidapi_ios.h in Headers */ = {isa = PBXBuildFile; fileRef = F3990E032A788303000D8759 /* SDL_hidapi_ios.h */; };
 		F3990E072A78833C000D8759 /* hid.m in Sources */ = {isa = PBXBuildFile; fileRef = A75FDAA523E2792500529352 /* hid.m */; };
 		F3A4909E2554D38600E92A8B /* SDL_hidapi_ps5.c in Sources */ = {isa = PBXBuildFile; fileRef = F3A4909D2554D38500E92A8B /* SDL_hidapi_ps5.c */; };
+		F3A8371C2F69C80100AD32B6 /* SDL_RealityKitHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A837162F69C80100AD32B6 /* SDL_RealityKitHelper.swift */; platformFilters = (xros, ); };
+		F3A895712F7D8AAA00B9E5C2 /* SDL_CurvedContentHosting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A8956D2F7D8AAA00B9E5C2 /* SDL_CurvedContentHosting.swift */; platformFilters = (xros, ); };
+		F3A895722F7D8AAA00B9E5C2 /* SDL_CurvedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A8956E2F7D8AAA00B9E5C2 /* SDL_CurvedContentView.swift */; platformFilters = (xros, ); };
+		F3A895792F7DC14400B9E5C2 /* SDL_UIKitBridge-objc.h in Headers */ = {isa = PBXBuildFile; fileRef = F3A895772F7DC14400B9E5C2 /* SDL_UIKitBridge-objc.h */; platformFilters = (xros, ); };
+		F3A8957A2F7DC14400B9E5C2 /* SDL_UIKitBridge-swift.h in Headers */ = {isa = PBXBuildFile; fileRef = F3A895782F7DC14400B9E5C2 /* SDL_UIKitBridge-swift.h */; platformFilters = (xros, ); };
+		F3A8957B2F7DC14400B9E5C2 /* SDL_UIKitBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = F3A895762F7DC14400B9E5C2 /* SDL_UIKitBridge.m */; platformFilters = (xros, ); };
+		F3A8957D2F7DC15500B9E5C2 /* SDL_uikitviewcontroller.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A8957C2F7DC15500B9E5C2 /* SDL_uikitviewcontroller.swift */; platformFilters = (xros, ); };
 		F3A9AE982C8A13C100AAC390 /* SDL_gpu_util.h in Headers */ = {isa = PBXBuildFile; fileRef = F3A9AE922C8A13C100AAC390 /* SDL_gpu_util.h */; };
 		F3A9AE992C8A13C100AAC390 /* SDL_render_gpu.c in Sources */ = {isa = PBXBuildFile; fileRef = F3A9AE932C8A13C100AAC390 /* SDL_render_gpu.c */; };
 		F3A9AE9A2C8A13C100AAC390 /* SDL_shaders_gpu.c in Sources */ = {isa = PBXBuildFile; fileRef = F3A9AE942C8A13C100AAC390 /* SDL_shaders_gpu.c */; };
@@ -559,7 +565,7 @@
 		F3FBB10A2DDF93AB0000F9A0 /* SDL_hidapi_gamesir.c in Sources */ = {isa = PBXBuildFile; fileRef = F3FBB1092DDF93AB0000F9A0 /* SDL_hidapi_gamesir.c */; };
 		F3FD042E2C9B755700824C4C /* SDL_hidapi_nintendo.h in Headers */ = {isa = PBXBuildFile; fileRef = F3FD042C2C9B755700824C4C /* SDL_hidapi_nintendo.h */; };
 		F3FD042F2C9B755700824C4C /* SDL_hidapi_steam_hori.c in Sources */ = {isa = PBXBuildFile; fileRef = F3FD042D2C9B755700824C4C /* SDL_hidapi_steam_hori.c */; };
-		FA73671D19A540EF004122E4 /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FA73671C19A540EF004122E4 /* CoreVideo.framework */; platformFilters = (ios, maccatalyst, macos, tvos, ); settings = {ATTRIBUTES = (Required, ); }; };
+		FA73671D19A540EF004122E4 /* CoreVideo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FA73671C19A540EF004122E4 /* CoreVideo.framework */; platformFilters = (ios, maccatalyst, macos, tvos, xros, ); settings = {ATTRIBUTES = (Required, ); }; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -617,6 +623,7 @@
 		00D0D08310675DD9004B05EF /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
 		02D6A1C128A84B8F00A7F001 /* SDL_hidapi_sinput.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_sinput.c; sourceTree = "<group>"; };
 		1485C32F2BBA4A0C0063985B /* UniformTypeIdentifiers.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UniformTypeIdentifiers.framework; path = System/Library/Frameworks/UniformTypeIdentifiers.framework; sourceTree = SDKROOT; };
+		3AFD09E92F9766BA00208BA9 /* SDL_CurvedUIShader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDL_CurvedUIShader.swift; sourceTree = "<group>"; };
 		5616CA49252BB2A5005D5928 /* SDL_url.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = SDL_url.c; sourceTree = "<group>"; };
 		5616CA4A252BB2A6005D5928 /* SDL_sysurl.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SDL_sysurl.h; sourceTree = "<group>"; };
 		5616CA4B252BB2A6005D5928 /* SDL_sysurl.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = SDL_sysurl.m; sourceTree = "<group>"; };
@@ -970,7 +977,6 @@
 		F338A1192D1B37E4007CDFDF /* SDL_tray.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SDL_tray.c; sourceTree = "<group>"; };
 		F3395BA72D9A5971007246C8 /* SDL_hidapi_8bitdo.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_8bitdo.c; sourceTree = "<group>"; };
 		F3395BA72D9A5971007246C9 /* SDL_hidapi_flydigi.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_flydigi.c; sourceTree = "<group>"; };
-		F3FBB1092DDF93AB0000F9A0 /* SDL_hidapi_gamesir.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_gamesir.c; sourceTree = "<group>"; };
 		F344003C2D4022E1003F26D7 /* INSTALL.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = INSTALL.md; sourceTree = "<group>"; };
 		F362B9152B3349E200D30B94 /* controller_list.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = controller_list.h; sourceTree = "<group>"; };
 		F362B9162B3349E200D30B94 /* SDL_gamepad_c.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SDL_gamepad_c.h; sourceTree = "<group>"; };
@@ -1026,6 +1032,13 @@
 		F3990E022A788303000D8759 /* SDL_hidapi_mac.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SDL_hidapi_mac.h; sourceTree = "<group>"; };
 		F3990E032A788303000D8759 /* SDL_hidapi_ios.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SDL_hidapi_ios.h; sourceTree = "<group>"; };
 		F3A4909D2554D38500E92A8B /* SDL_hidapi_ps5.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_ps5.c; sourceTree = "<group>"; };
+		F3A837162F69C80100AD32B6 /* SDL_RealityKitHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDL_RealityKitHelper.swift; sourceTree = "<group>"; };
+		F3A8956D2F7D8AAA00B9E5C2 /* SDL_CurvedContentHosting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDL_CurvedContentHosting.swift; sourceTree = "<group>"; };
+		F3A8956E2F7D8AAA00B9E5C2 /* SDL_CurvedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDL_CurvedContentView.swift; sourceTree = "<group>"; };
+		F3A895762F7DC14400B9E5C2 /* SDL_UIKitBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SDL_UIKitBridge.m; sourceTree = "<group>"; };
+		F3A895772F7DC14400B9E5C2 /* SDL_UIKitBridge-objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SDL_UIKitBridge-objc.h"; sourceTree = "<group>"; };
+		F3A895782F7DC14400B9E5C2 /* SDL_UIKitBridge-swift.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SDL_UIKitBridge-swift.h"; sourceTree = "<group>"; };
+		F3A8957C2F7DC15500B9E5C2 /* SDL_uikitviewcontroller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDL_uikitviewcontroller.swift; sourceTree = "<group>"; };
 		F3A9AE922C8A13C100AAC390 /* SDL_gpu_util.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = SDL_gpu_util.h; sourceTree = "<group>"; };
 		F3A9AE932C8A13C100AAC390 /* SDL_render_gpu.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = SDL_render_gpu.c; sourceTree = "<group>"; };
 		F3A9AE942C8A13C100AAC390 /* SDL_shaders_gpu.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = SDL_shaders_gpu.c; sourceTree = "<group>"; };
@@ -1149,6 +1162,7 @@
 		F3FA5A1A2B59ACE000FEAD97 /* yuv_rgb_lsx.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = yuv_rgb_lsx.c; sourceTree = "<group>"; };
 		F3FA5A1B2B59ACE000FEAD97 /* yuv_rgb_lsx.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = yuv_rgb_lsx.h; sourceTree = "<group>"; };
 		F3FA5A1C2B59ACE000FEAD97 /* yuv_rgb_common.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = yuv_rgb_common.h; sourceTree = "<group>"; };
+		F3FBB1092DDF93AB0000F9A0 /* SDL_hidapi_gamesir.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_gamesir.c; sourceTree = "<group>"; };
 		F3FD042C2C9B755700824C4C /* SDL_hidapi_nintendo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = SDL_hidapi_nintendo.h; sourceTree = "<group>"; };
 		F3FD042D2C9B755700824C4C /* SDL_hidapi_steam_hori.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = SDL_hidapi_steam_hori.c; sourceTree = "<group>"; };
 		F59C710600D5CB5801000001 /* SDL.info */ = {isa = PBXFileReference; fileEncoding = 30; lastKnownFileType = text; path = SDL.info; sourceTree = "<group>"; };
@@ -1738,8 +1752,15 @@
 		A7D8A61823E2513D00DCD162 /* uikit */ = {
 			isa = PBXGroup;
 			children = (
+				F3A8956D2F7D8AAA00B9E5C2 /* SDL_CurvedContentHosting.swift */,
+				F3A8956E2F7D8AAA00B9E5C2 /* SDL_CurvedContentView.swift */,
+				3AFD09E92F9766BA00208BA9 /* SDL_CurvedUIShader.swift */,
+				F3A837162F69C80100AD32B6 /* SDL_RealityKitHelper.swift */,
 				A7D8A62F23E2513D00DCD162 /* SDL_uikitappdelegate.h */,
 				A7D8A61E23E2513D00DCD162 /* SDL_uikitappdelegate.m */,
+				F3A895762F7DC14400B9E5C2 /* SDL_UIKitBridge.m */,
+				F3A895772F7DC14400B9E5C2 /* SDL_UIKitBridge-objc.h */,
+				F3A895782F7DC14400B9E5C2 /* SDL_UIKitBridge-swift.h */,
 				A7D8A62123E2513D00DCD162 /* SDL_uikitclipboard.h */,
 				A7D8A62A23E2513D00DCD162 /* SDL_uikitclipboard.m */,
 				A7D8A62D23E2513D00DCD162 /* SDL_uikitevents.h */,
@@ -1754,18 +1775,19 @@
 				A7D8A62323E2513D00DCD162 /* SDL_uikitopengles.m */,
 				A7D8A62B23E2513D00DCD162 /* SDL_uikitopenglview.h */,
 				A7D8A62023E2513D00DCD162 /* SDL_uikitopenglview.m */,
+				000063D3D80F97ADC7770000 /* SDL_uikitpen.h */,
+				000053D344416737F6050000 /* SDL_uikitpen.m */,
 				A7D8A62223E2513D00DCD162 /* SDL_uikitvideo.h */,
 				A7D8A63223E2513D00DCD162 /* SDL_uikitvideo.m */,
 				A7D8A61923E2513D00DCD162 /* SDL_uikitview.h */,
 				A7D8A62923E2513D00DCD162 /* SDL_uikitview.m */,
 				A7D8A62423E2513D00DCD162 /* SDL_uikitviewcontroller.h */,
 				A7D8A63023E2513D00DCD162 /* SDL_uikitviewcontroller.m */,
+				F3A8957C2F7DC15500B9E5C2 /* SDL_uikitviewcontroller.swift */,
 				A7D8A63323E2513D00DCD162 /* SDL_uikitvulkan.h */,
 				A7D8A62523E2513D00DCD162 /* SDL_uikitvulkan.m */,
 				A7D8A62723E2513D00DCD162 /* SDL_uikitwindow.h */,
 				A7D8A61A23E2513D00DCD162 /* SDL_uikitwindow.m */,
-				000063D3D80F97ADC7770000 /* SDL_uikitpen.h */,
-				000053D344416737F6050000 /* SDL_uikitpen.m */,
 			);
 			path = uikit;
 			sourceTree = "<group>";
@@ -2365,17 +2387,6 @@
 			path = vulkan;
 			sourceTree = "<group>";
 		};
-		E4F2578B2C81903800FCEAFC /* xr */ = {
-			isa = PBXGroup;
-			children = (
-				E4F257882C81903800FCEAFC /* SDL_gpu_openxr.c */,
-				E4F257892C81903800FCEAFC /* SDL_openxrdyn.c */,
-				E4F2578A2C81903800FCEAFC /* SDL_gpu_openxr_c.h */,
-				E4F2578C2C81903800FCEAFC /* SDL_openxr_internal.h */,
-			);
-			path = xr;
-			sourceTree = "<group>";
-		};
 		E4F257872C81903800FCEAFC /* gpu */ = {
 			isa = PBXGroup;
 			children = (
@@ -2388,6 +2399,17 @@
 			path = gpu;
 			sourceTree = "<group>";
 		};
+		E4F2578B2C81903800FCEAFC /* xr */ = {
+			isa = PBXGroup;
+			children = (
+				E4F257882C81903800FCEAFC /* SDL_gpu_openxr.c */,
+				E4F257892C81903800FCEAFC /* SDL_openxrdyn.c */,
+				E4F2578A2C81903800FCEAFC /* SDL_gpu_openxr_c.h */,
+				E4F2578C2C81903800FCEAFC /* SDL_openxr_internal.h */,
+			);
+			path = xr;
+			sourceTree = "<group>";
+		};
 		F338A1142D1B3735007CDFDF /* tray */ = {
 			isa = PBXGroup;
 			children = (
@@ -2561,6 +2583,8 @@
 				F3EFA5ED2D5AB97300BCF22F /* SDL_stb_c.h in Headers */,
 				F3EFA5EE2D5AB97300BCF22F /* stb_image.h in Headers */,
 				F3EFA5EF2D5AB97300BCF22F /* SDL_surface_c.h in Headers */,
+				F3A895792F7DC14400B9E5C2 /* SDL_UIKitBridge-objc.h in Headers */,
+				F3A8957A2F7DC14400B9E5C2 /* SDL_UIKitBridge-swift.h in Headers */,
 				A7D8AE8E23E2514100DCD162 /* SDL_cocoakeyboard.h in Headers */,
 				A7D8AF0623E2514100DCD162 /* SDL_cocoamessagebox.h in Headers */,
 				A7D8AEB223E2514100DCD162 /* SDL_cocoametalview.h in Headers */,
@@ -2843,6 +2867,9 @@
 			attributes = {
 				LastUpgradeCheck = 1130;
 				TargetAttributes = {
+					BECDF5FE0761BA81005FE872 = {
+						LastSwiftMigration = 2630;
+					};
 					F3676F582A7885080091160D = {
 						CreatedOnToolsVersion = 14.3.1;
 					};
@@ -2931,6 +2958,7 @@
 			buildActionMask = 2147483647;
 			files = (
 				A7D8B9E323E2514400DCD162 /* SDL_drawline.c in Sources */,
+				F3A8957B2F7DC14400B9E5C2 /* SDL_UIKitBridge.m in Sources */,
 				A7D8AE7C23E2514100DCD162 /* SDL_yuv.c in Sources */,
 				A7D8B62F23E2514300DCD162 /* SDL_sysfilesystem.m in Sources */,
 				A7D8B41C23E2514300DCD162 /* SDL_systls.c in Sources */,
@@ -3059,6 +3087,7 @@
 				F3B439512C935C2400792030 /* SDL_dummyprocess.c in Sources */,
 				A7D8B76423E2514300DCD162 /* SDL_mixer.c in Sources */,
 				A7D8BB5723E2514500DCD162 /* SDL_events.c in Sources */,
+				F3A8957D2F7DC15500B9E5C2 /* SDL_uikitviewcontroller.swift in Sources */,
 				A7D8ADE623E2514100DCD162 /* SDL_blit_0.c in Sources */,
 				89E5801E2D03602200DAF6D3 /* SDL_hidapi_lg4ff.c in Sources */,
 				A7D8B8A823E2514400DCD162 /* SDL_diskaudio.c in Sources */,
@@ -3086,6 +3115,7 @@
 				A7D8B56323E2514300DCD162 /* SDL_hidapi_gamecube.c in Sources */,
 				A7D8B4DC23E2514300DCD162 /* SDL_joystick.c in Sources */,
 				A7D8BA4923E2514400DCD162 /* SDL_render_gles2.c in Sources */,
+				F3A8371C2F69C80100AD32B6 /* SDL_RealityKitHelper.swift in Sources */,
 				A7D8AC2D23E2514100DCD162 /* SDL_surface.c in Sources */,
 				A7D8B54B23E2514300DCD162 /* SDL_hidapi_xboxone.c in Sources */,
 				A7D8AD2323E2514100DCD162 /* SDL_blit_auto.c in Sources */,
@@ -3141,6 +3171,8 @@
 				A7D8A94B23E2514000DCD162 /* SDL.c in Sources */,
 				A7D8AEA023E2514100DCD162 /* SDL_cocoavulkan.m in Sources */,
 				A7D8AB6123E2514100DCD162 /* SDL_offscreenwindow.c in Sources */,
+				F3A895712F7D8AAA00B9E5C2 /* SDL_CurvedContentHosting.swift in Sources */,
+				F3A895722F7D8AAA00B9E5C2 /* SDL_CurvedContentView.swift in Sources */,
 				566E26D8246274CC00718109 /* SDL_locale.c in Sources */,
 				63134A262A7902FD0021E9A6 /* SDL_pen.c in Sources */,
 				000040E76FDC6AE48CBF0000 /* SDL_hashtable.c in Sources */,
@@ -3153,6 +3185,7 @@
 				00002B20A48E055EB0350000 /* SDL_camera_coremedia.m in Sources */,
 				000080903BC03006F24E0000 /* SDL_filesystem.c in Sources */,
 				F3FBB1082DDF93AB0000F99F /* SDL_hidapi_flydigi.c in Sources */,
+				3AFD09EA2F9766BA00208BA9 /* SDL_CurvedUIShader.swift in Sources */,
 				F3FBB10A2DDF93AB0000F9A0 /* SDL_hidapi_gamesir.c in Sources */,
 				0000481D255AF155B42C0000 /* SDL_sysfsops.c in Sources */,
 				0000494CC93F3E624D3C0000 /* SDL_systime.c in Sources */,
@@ -3239,13 +3272,18 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = F3F7BE3B2CBD79D200C984AF /* config.xcconfig */;
 			buildSettings = {
+				CLANG_ENABLE_MODULES = YES;
 				CLANG_LINK_OBJC_RUNTIME = NO;
-				GCC_PREPROCESSOR_DEFINITIONS = GLES_SILENCE_DEPRECATION;
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				DEFINES_MODULE = YES;
 				GCC_GENERATE_DEBUGGING_SYMBOLS = YES;
 				EXPORTED_SYMBOLS_FILE = "$(SRCROOT)/../../src/dynapi/SDL_dynapi.exports";
+				GCC_PREPROCESSOR_DEFINITIONS = GLES_SILENCE_DEPRECATION;
 				OTHER_LDFLAGS = "-liconv";
 				SUPPORTS_MACCATALYST = YES;
+				"SWIFT_OBJC_BRIDGING_HEADER[sdk=xr*]" = "../../src/video/uikit/SDL_UIKitBridge-swift.h";
+				SWIFT_VERSION = 6.0;
+				XROS_DEPLOYMENT_TARGET = 26.0;
 			};
 			name = Release;
 		};
@@ -3305,13 +3343,19 @@
 			isa = XCBuildConfiguration;
 			baseConfigurationReference = F3F7BE3B2CBD79D200C984AF /* config.xcconfig */;
 			buildSettings = {
+				CLANG_ENABLE_MODULES = YES;
 				CLANG_LINK_OBJC_RUNTIME = NO;
-				GCC_PREPROCESSOR_DEFINITIONS = GLES_SILENCE_DEPRECATION;
 				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				DEFINES_MODULE = YES;
 				GCC_GENERATE_DEBUGGING_SYMBOLS = YES;
 				EXPORTED_SYMBOLS_FILE = "$(SRCROOT)/../../src/dynapi/SDL_dynapi.exports";
+				GCC_PREPROCESSOR_DEFINITIONS = GLES_SILENCE_DEPRECATION;
 				OTHER_LDFLAGS = "-liconv";
 				SUPPORTS_MACCATALYST = YES;
+				"SWIFT_OBJC_BRIDGING_HEADER[sdk=xr*]" = "../../src/video/uikit/SDL_UIKitBridge-swift.h";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 6.0;
+				XROS_DEPLOYMENT_TARGET = 26.0;
 			};
 			name = Debug;
 		};

+ 2 - 1
include/SDL3/SDL_events.h

@@ -163,8 +163,9 @@ typedef enum SDL_EventType
                                              associated with the window. Otherwise, the handle has already been destroyed and all resources
                                              associated with it are invalid */
     SDL_EVENT_WINDOW_HDR_STATE_CHANGED, /**< Window HDR properties have changed */
+    SDL_EVENT_WINDOW_CURVATURE_CHANGED, /**< Window curvature has changed to data1 (on visionOS) */
     SDL_EVENT_WINDOW_FIRST = SDL_EVENT_WINDOW_SHOWN,
-    SDL_EVENT_WINDOW_LAST = SDL_EVENT_WINDOW_HDR_STATE_CHANGED,
+    SDL_EVENT_WINDOW_LAST = SDL_EVENT_WINDOW_CURVATURE_CHANGED,
 
     /* Keyboard events */
     SDL_EVENT_KEY_DOWN        = 0x300, /**< Key pressed */

+ 11 - 0
include/SDL3/SDL_video.h

@@ -1384,6 +1384,11 @@ extern SDL_DECLSPEC SDL_Window * SDLCALL SDL_CreatePopupWindow(SDL_Window *paren
  *   popup windows and have the behaviors and guidelines outlined in
  *   SDL_CreatePopupWindow().
  *
+ * These are additional supported properties with visionOS:
+ *
+ * - `SDL_PROP_WINDOW_CREATE_CURVATURE_FLOAT`: the curvature of the window on visionOS. Curved windows have square corners and additional controls for more immersive gaming.
+ * This can be -1 (disabled), which is the default, 0 (no curve), or set to a specific curvature radius in millimeters. A common value for a gaming monitor is 1000.
+ *
  * If this window is being created to be used with an SDL_Renderer, you should
  * not add a graphics API specific property
  * (`SDL_PROP_WINDOW_CREATE_OPENGL_BOOLEAN`, etc), as SDL will handle that
@@ -1446,6 +1451,7 @@ extern SDL_DECLSPEC SDL_Window * SDLCALL SDL_CreateWindowWithProperties(SDL_Prop
 #define SDL_PROP_WINDOW_CREATE_X11_WINDOW_NUMBER                   "SDL.window.create.x11.window"
 #define SDL_PROP_WINDOW_CREATE_EMSCRIPTEN_CANVAS_ID_STRING         "SDL.window.create.emscripten.canvas_id"
 #define SDL_PROP_WINDOW_CREATE_EMSCRIPTEN_KEYBOARD_ELEMENT_STRING  "SDL.window.create.emscripten.keyboard_element"
+#define SDL_PROP_WINDOW_CREATE_CURVATURE_FLOAT                     "SDL.window.create.curvature"
 
 /**
  * Get the numeric ID of a window.
@@ -1624,6 +1630,10 @@ extern SDL_DECLSPEC SDL_Window * SDLCALL SDL_GetWindowParent(SDL_Window *window)
  * - `SDL_PROP_WINDOW_EMSCRIPTEN_KEYBOARD_ELEMENT_STRING`: the keyboard
  *   element that associates keyboard events to this window
  *
+ * On visionOS:
+ *
+ * - `SDL_PROP_WINDOW_CURVATURE_FLOAT`: the curvature of the window in curved mode on visionOS. This value is updated dynamically when changed via the screen ornaments. This can be 0 (no curve), or a specific curvature radius in millimeters. A common value for a gaming monitor is 1000.
+ *
  * \param window the window to query.
  * \returns a valid property ID on success or 0 on failure; call
  *          SDL_GetError() for more information.
@@ -1673,6 +1683,7 @@ extern SDL_DECLSPEC SDL_PropertiesID SDLCALL SDL_GetWindowProperties(SDL_Window
 #define SDL_PROP_WINDOW_X11_WINDOW_NUMBER                           "SDL.window.x11.window"
 #define SDL_PROP_WINDOW_EMSCRIPTEN_CANVAS_ID_STRING                 "SDL.window.emscripten.canvas_id"
 #define SDL_PROP_WINDOW_EMSCRIPTEN_KEYBOARD_ELEMENT_STRING          "SDL.window.emscripten.keyboard_element"
+#define SDL_PROP_WINDOW_CURVATURE_FLOAT                             "SDL.window.curvature"
 
 /**
  * Get the window flags.

+ 1 - 0
src/events/SDL_events.c

@@ -565,6 +565,7 @@ int SDL_GetEventDescription(const SDL_Event *event, char *buf, int buflen)
         SDL_WINDOWEVENT_CASE(SDL_EVENT_WINDOW_LEAVE_FULLSCREEN);
         SDL_WINDOWEVENT_CASE(SDL_EVENT_WINDOW_DESTROYED);
         SDL_WINDOWEVENT_CASE(SDL_EVENT_WINDOW_HDR_STATE_CHANGED);
+        SDL_WINDOWEVENT_CASE(SDL_EVENT_WINDOW_CURVATURE_CHANGED);
 #undef SDL_WINDOWEVENT_CASE
 
 #define PRINT_KEYDEV_EVENT(event) (void)SDL_snprintf(details, sizeof(details), " (timestamp=%" SDL_PRIu64 " which=%u)", event->kdevice.timestamp, (uint)event->kdevice.which)

+ 5 - 0
src/render/SDL_render.c

@@ -177,7 +177,12 @@ bool SDL_AddSupportedTextureFormat(SDL_Renderer *renderer, SDL_PixelFormat forma
 
 void SDL_SetupRendererColorspace(SDL_Renderer *renderer, SDL_PropertiesID props)
 {
+#ifdef SDL_PLATFORM_VISIONOS
+    // The RealityKit texture always renders in linear colorspace
+    renderer->output_colorspace = SDL_COLORSPACE_SRGB_LINEAR;
+#else
     renderer->output_colorspace = (SDL_Colorspace)SDL_GetNumberProperty(props, SDL_PROP_RENDERER_CREATE_OUTPUT_COLORSPACE_NUMBER, SDL_COLORSPACE_SRGB);
+#endif
 }
 
 bool SDL_RenderingLinearSpace(SDL_Renderer *renderer)

+ 108 - 13
src/render/metal/SDL_render_metal.m

@@ -35,6 +35,9 @@
 #endif
 #ifdef SDL_VIDEO_DRIVER_UIKIT
 #import <UIKit/UIKit.h>
+#ifdef SDL_PLATFORM_VISIONOS
+#import "../../video/uikit/SDL_UIKitBridge-objc.h"
+#endif
 #endif
 
 // Regenerate these with build-metal-shaders.sh
@@ -139,6 +142,9 @@ typedef struct METAL_ShaderPipelines
 @property(nonatomic, assign) METAL_ShaderPipelines *activepipelines;
 @property(nonatomic, assign) METAL_ShaderPipelines *allpipelines;
 @property(nonatomic, assign) int pipelinescount;
+#ifdef SDL_PLATFORM_VISIONOS
+@property(nonatomic, retain) id<MTLTexture> mtlrealitykittexture;
+#endif
 @end
 
 @implementation SDL3METAL_RenderData
@@ -453,16 +459,25 @@ static bool METAL_ActivateRenderCommandEncoder(SDL_Renderer *renderer, MTLLoadAc
             SDL3METAL_TextureData *texdata = (__bridge SDL3METAL_TextureData *)renderer->target->internal;
             mtltexture = texdata.mtltexture;
         } else {
-            if (data.mtlbackbuffer == nil) {
-                /* The backbuffer's contents aren't guaranteed to persist after
-                 * presenting, so we can leave it undefined when loading it. */
-                data.mtlbackbuffer = [data.mtllayer nextDrawable];
-                if (load == MTLLoadActionLoad) {
-                    load = MTLLoadActionDontCare;
+#ifdef SDL_PLATFORM_VISIONOS
+            if (renderer->window && SDL_UIKit_IsCurvedWindow(renderer->window)) {
+                data.mtlrealitykittexture = SDL_UIKit_GetCurvedDisplayTexture(renderer->window, [data.mtlcmdqueue commandBuffer], (int)data.mtllayer.drawableSize.width, (int)data.mtllayer.drawableSize.height, data.mtllayer.pixelFormat);
+                mtltexture = data.mtlrealitykittexture;
+            } else
+#endif
+            {
+                // Standard rendering path: use CAMetalLayer drawable
+                if (data.mtlbackbuffer == nil) {
+                    // The backbuffer's contents aren't guaranteed to persist after
+                    // presenting, so we can leave it undefined when loading it.
+                    data.mtlbackbuffer = [data.mtllayer nextDrawable];
+                    if (load == MTLLoadActionLoad) {
+                        load = MTLLoadActionDontCare;
+                    }
+                }
+                if (data.mtlbackbuffer != nil) {
+                    mtltexture = data.mtlbackbuffer.texture;
                 }
-            }
-            if (data.mtlbackbuffer != nil) {
-                mtltexture = data.mtlbackbuffer.texture;
             }
         }
 
@@ -1922,12 +1937,57 @@ static bool METAL_RunCommandQueue(SDL_Renderer *renderer, SDL_RenderCommand *cmd
     }
 }
 
+#ifdef SDL_PLATFORM_VISIONOS
+static id<MTLTexture> METAL_CopyToStagingTexture(SDL_Renderer *renderer, id<MTLTexture> texture, SDL_Rect *rect)
+{
+    SDL3METAL_RenderData *data = (__bridge SDL3METAL_RenderData *)renderer->internal;
+    MTLTextureDescriptor *desc;
+    id<MTLTexture> stagingtex;
+    id<MTLBlitCommandEncoder> blitcmd;
+
+    desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:texture.pixelFormat
+                                                              width:rect->w
+                                                             height:rect->h
+                                                          mipmapped:NO];
+    if (desc == nil) {
+        SDL_OutOfMemory();
+        return nil;
+    }
+
+    stagingtex = [data.mtldevice newTextureWithDescriptor:desc];
+    if (stagingtex == nil) {
+        SDL_OutOfMemory();
+        return nil;
+    }
+
+    blitcmd = [data.mtlcmdbuffer blitCommandEncoder];
+
+    [blitcmd copyFromTexture:texture
+                 sourceSlice:0
+                 sourceLevel:0
+                sourceOrigin:MTLOriginMake(rect->x, rect->y, 0)
+                  sourceSize:MTLSizeMake(rect->w, rect->h, 1)
+                   toTexture:stagingtex
+            destinationSlice:0
+            destinationLevel:0
+           destinationOrigin:MTLOriginMake(0, 0, 0)];
+
+    [blitcmd endEncoding];
+
+    rect->x = 0;
+    rect->y = 0;
+
+    return stagingtex;
+}
+#endif // SDL_PLATFORM_VISIONOS
+
 static SDL_Surface *METAL_RenderReadPixels(SDL_Renderer *renderer, const SDL_Rect *rect)
 {
     @autoreleasepool {
         SDL3METAL_RenderData *data = (__bridge SDL3METAL_RenderData *)renderer->internal;
         id<MTLTexture> mtltexture;
         MTLRegion mtlregion;
+        SDL_Rect read_rect = *rect;
         Uint32 format;
         SDL_Surface *surface;
 
@@ -1951,6 +2011,15 @@ static SDL_Surface *METAL_RenderReadPixels(SDL_Renderer *renderer, const SDL_Rec
         }
 #endif
 
+#ifdef SDL_PLATFORM_VISIONOS
+        if (!renderer->target && data.mtlrealitykittexture) {
+            mtltexture = METAL_CopyToStagingTexture(renderer, mtltexture, &read_rect);
+            if (mtltexture == nil) {
+                return NULL;
+            }
+        }
+#endif
+
         /* Commit the current command buffer and wait until it's completed, to make
          * sure the GPU has finished rendering to it by the time we read it. */
         [data.mtlcmdbuffer commit];
@@ -1958,7 +2027,7 @@ static SDL_Surface *METAL_RenderReadPixels(SDL_Renderer *renderer, const SDL_Rec
         data.mtlcmdencoder = nil;
         data.mtlcmdbuffer = nil;
 
-        mtlregion = MTLRegionMake2D(rect->x, rect->y, rect->w, rect->h);
+        mtlregion = MTLRegionMake2D(read_rect.x, read_rect.y, read_rect.w, read_rect.h);
 
         switch (mtltexture.pixelFormat) {
         case MTLPixelFormatBGRA8Unorm:
@@ -1991,9 +2060,16 @@ static SDL_Surface *METAL_RenderReadPixels(SDL_Renderer *renderer, const SDL_Rec
             SDL_SetError("Unknown framebuffer pixel format");
             return NULL;
         }
-        surface = SDL_CreateSurface(rect->w, rect->h, format);
+        surface = SDL_CreateSurface(read_rect.w, read_rect.h, format);
         if (surface) {
             [mtltexture getBytes:surface->pixels bytesPerRow:surface->pitch fromRegion:mtlregion mipmapLevel:0];
+            if (SDL_RenderingLinearSpace(renderer) &&
+                (!SDL_ISPIXELFORMAT_10BIT(format) && !SDL_ISPIXELFORMAT_FLOAT(format))) {
+                if (!SDL_ConvertPixelsAndColorspace(surface->w, surface->h, format, SDL_COLORSPACE_SRGB_LINEAR, 0, surface->pixels, surface->pitch, format, SDL_COLORSPACE_SRGB, 0, surface->pixels, surface->pitch)) {
+                    SDL_DestroySurface(surface);
+                    return NULL;
+                }
+            }
         }
         return surface;
     }
@@ -2022,8 +2098,22 @@ static bool METAL_RenderPresent(SDL_Renderer *renderer)
         // If we don't have a drawable to present, don't try to present it.
         //  But we'll still try to commit the command buffer in case it was already enqueued.
         if (ready) {
-            SDL_assert(data.mtlbackbuffer != nil);
-            [data.mtlcmdbuffer presentDrawable:data.mtlbackbuffer];
+#ifdef SDL_PLATFORM_VISIONOS
+            if (data.mtlrealitykittexture) {
+                // Generate mipmaps
+                id<MTLBlitCommandEncoder> blitcmd = [data.mtlcmdbuffer blitCommandEncoder];
+
+                [blitcmd generateMipmapsForTexture:data.mtlrealitykittexture];
+                [blitcmd endEncoding];
+
+                data.mtlrealitykittexture = nil;
+            }
+            else
+#endif
+            {
+                SDL_assert(data.mtlbackbuffer != nil);
+                [data.mtlcmdbuffer presentDrawable:data.mtlbackbuffer];
+            }
         }
 
         [data.mtlcmdbuffer commit];
@@ -2057,6 +2147,11 @@ static void METAL_DestroyRenderer(SDL_Renderer *renderer)
                 [data.mtlcmdencoder endEncoding];
             }
 
+            if (data.mtlcmdbuffer != nil) {
+                [data.mtlcmdbuffer commit];
+                [data.mtlcmdbuffer waitUntilCompleted];
+            }
+
             DestroyAllPipelines(data.allpipelines, data.pipelinescount);
 
             /* Release the metal view instead of destroying it,

+ 410 - 0
src/video/uikit/SDL_CurvedContentHosting.swift

@@ -0,0 +1,410 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+import SwiftUI
+import RealityKit
+import Metal
+
+// Icons used by buttons below
+
+// Flat button
+/* SVG:
+ <svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M133.333 400H666.667" stroke="black" stroke-width="66.6667" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ */
+struct FlatButtonIcon : Shape {
+    func path(in rect: CGRect) -> Path {
+        var path = Path()
+        let width = rect.size.width
+        let height = rect.size.height
+        var strokePath = Path()
+        strokePath.move(to: CGPoint(x: 0.16667*width, y: 0.5*height))
+        strokePath.addLine(to: CGPoint(x: 0.83333*width, y: 0.5*height))
+        path.addPath(strokePath.strokedPath(StrokeStyle(lineWidth: 0.08333*width, lineCap: .round, lineJoin: .round, miterLimit: 4)))
+        return path
+    }
+}
+
+// Curved button
+/* SVG:
+ <svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M133 380C311 317.333 489 317.333 667 380" stroke="black" stroke-width="66.6667" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ */
+struct CurvedButtonIcon : Shape {
+    func path(in rect: CGRect) -> Path {
+        var path = Path()
+        let width = rect.size.width
+        let height = rect.size.height
+        var strokePath = Path()
+        strokePath.move(to: CGPoint(x: 0.16625*width, y: 0.475*height))
+        strokePath.addCurve(to: CGPoint(x: 0.83375*width, y: 0.475*height), control1: CGPoint(x: 0.38875*width, y: 0.39667*height), control2: CGPoint(x: 0.61125*width, y: 0.39667*height))
+        path.addPath(strokePath.strokedPath(StrokeStyle(lineWidth: 0.08333*width, lineCap: .round, lineJoin: .round, miterLimit: 4)))
+        return path
+    }
+}
+
+// Curviest button
+/* SVG:
+ <svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M133 370C310.667 230 488.333 230 666 370" stroke="black" stroke-width="66.6667" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ */
+struct CurviestButtonIcon : Shape {
+    func path(in rect: CGRect) -> Path {
+        var path = Path()
+        let width = rect.size.width
+        let height = rect.size.height
+        var strokePath = Path()
+        strokePath.move(to: CGPoint(x: 0.16625*width, y: 0.4625*height))
+        strokePath.addCurve(to: CGPoint(x: 0.8325*width, y: 0.4625*height), control1: CGPoint(x: 0.38833*width, y: 0.2875*height), control2: CGPoint(x: 0.61042*width, y: 0.2875*height))
+        path.addPath(strokePath.strokedPath(StrokeStyle(lineWidth: 0.08333*width, lineCap: .round, lineJoin: .round, miterLimit: 4)))
+        return path
+    }
+}
+
+/// UIHostingController subclass that hides the visionOS glass background.
+internal class SDL_ClearHostingController<Content: View>: UIHostingController<Content> {
+    override var preferredContainerBackgroundStyle: UIContainerBackgroundStyle {
+        return .hidden
+    }
+}
+
+/// ObjC-accessible wrapper that manages presenting SDL curved content
+/// via a UIHostingController
+@MainActor
+@objc(SDL_CurvedContentHosting)
+internal class SDL_CurvedContentHosting: NSObject {
+    private let settings = SDL_CurvedContentSettings()
+
+    private let helper = SDL_RealityKitHelper()
+
+    private var hostingController: SDL_ClearHostingController<SDL_CurvedContentView>?
+
+    @objc public override init() {
+        //NSLog("SDL_CurvedContentHosting init")
+        super.init()
+    }
+
+    /// Present the curved content view full-screen from the given view controller.
+    /// Uses two-phase presentation: first bootstraps the RealityView as a hidden
+    /// child VC, then presents modally (without animation) once content is ready.
+    /// Modal presentation is required on visionOS to get an independent depth budget
+    /// that doesn't clip curved mesh content extending forward from the window.
+    @objc public func present(from viewController: UIViewController) {
+        let contentView = SDL_CurvedContentView(helper: helper, settings: settings, onContentReady: { [weak self] in
+            guard let self, let hc = self.hostingController else { return }
+
+            hc.willMove(toParent: nil)
+            hc.view.removeFromSuperview()
+            hc.removeFromParent()
+            hc.view.layer.opacity = 1
+
+            //NSLog("SDL_CurvedContentHosting: RealityView content ready - presenting modally")
+            viewController.present(hc, animated: false) { [weak self] in
+                self?.updateOrnaments()
+            }
+        })
+
+        // Spin up an async task to present / dismiss ornaments when there are updates to the scene state.
+        let settings = self.settings
+        let sceneStateObservations = Observations { [weak settings] in
+            guard let settings else { return nil as (SDL_CurvedContentSettings.SceneState, SDL_CurvedContentSettings.InputType, Bool, Bool)? }
+            return (settings.sceneState, settings.inputType, settings.isSnapped, settings.settingsExpanded)
+        }
+        Task { [weak self] in
+            for await _ in sceneStateObservations {
+                guard let self else { return }
+                self.updateOrnaments()
+            }
+        }
+
+        let hc = SDL_ClearHostingController(rootView: contentView)
+        hc.modalPresentationStyle = .fullScreen
+        hc.view.backgroundColor = .clear
+        hostingController = hc
+
+        hc.view.layer.opacity = 0
+        viewController.addChild(hc)
+        hc.view.frame = viewController.view.bounds
+        viewController.view.addSubview(hc.view)
+        hc.didMove(toParent: viewController)
+
+        //NSLog("SDL_CurvedContentHosting: Bootstrapping RealityView as hidden child")
+    }
+
+    private func updateOrnaments() {
+        guard let hostingController else { return }
+        let settings = self.settings
+        let sceneState = settings.sceneState
+        UIView.animate(withDuration: 0.0) {
+            if sceneState == .interactive {
+                var sceneAnchor: UnitPoint
+                var contentAlignment: Alignment
+                if settings.isSnapped {
+                    if settings.settingsExpanded {
+                        sceneAnchor = .bottom
+                        contentAlignment = .center
+                    } else {
+                        sceneAnchor = .bottom
+                        contentAlignment = .top
+                    }
+                } else {
+                    if settings.settingsExpanded {
+                        sceneAnchor = .leading
+                        contentAlignment = .center
+                    } else {
+                        sceneAnchor = .leading
+                        contentAlignment = .trailing
+                    }
+                }
+                hostingController.ornaments = [
+                    UIHostingOrnament(sceneAnchor: sceneAnchor, contentAlignment: contentAlignment) {
+                        SDL_SettingsPanelView(settings: settings)
+                    }
+                ]
+            } else {
+                hostingController.ornaments = []
+            }
+        }
+    }
+
+    /// Get the display texture for this frame.
+    @objc public func getDisplayTexture(_ commandBuffer: MTLCommandBuffer, width: Int, height: Int, pixelFormat: MTLPixelFormat) -> MTLTexture? {
+        return helper.getDisplayTexture(commandBuffer, width: width, height: height, pixelFormat: pixelFormat)
+    }
+}
+
+// MARK: - Settings Panel
+
+@Observable
+internal class SDL_CurvedContentSettings {
+    /// State of the app user interface, determined by the content view's state.
+    enum SceneState {
+        /// A state which allows the user to configure the scene.  Ornaments should be visible.
+        case interactive
+
+        /// A state which hides all UI except for the game itself.  Ornaments should not be visible.
+        case cinematic
+    }
+
+    enum InputType {
+        case eyes
+        case pointer
+    }
+
+    var inputType: InputType = .eyes
+    var showHover: Bool = true
+    var isDimmed: Bool = false
+    var curvatureRadius: Float = SDL_VisionOS_GetCurvature()
+    var sceneState: SceneState = .interactive
+    var isSnapped: Bool = false
+    var settingsExpanded: Bool = false
+}
+
+struct SDL_SettingsPanelView: View {
+    let settings: SDL_CurvedContentSettings
+    @State private var curvatureSlider: Float = 0.0
+
+    static let minimumCurvatureRadius: Float = 800.0
+    static let maximumCurvatureRadius: Float = 4500.0
+
+    static let curvatureSteps: [Float] = [
+        0,
+        4000,
+        3000,
+        2300,
+        1800,
+        1500,
+        1000,
+        800
+    ]
+
+    static let curvatureStepsSliderValue: [Float] = curvatureSteps.map {
+        if $0 <= 0.01 {
+            return 0 // flat
+        }
+        return 1.0 - ($0 - minimumCurvatureRadius) / (maximumCurvatureRadius - minimumCurvatureRadius)
+    }
+
+    private var curvatureLabel: String {
+        if settings.curvatureRadius > 0 {
+            return "\(Int(settings.curvatureRadius))R"
+        } else {
+            return ""
+        }
+    }
+
+    var body: some View {
+        if settings.settingsExpanded {
+            expandedPanel
+        } else {
+            collapsedBar
+        }
+    }
+
+    // MARK: Collapsed
+
+    private var collapsedBar: some View {
+        Button(action: { withAnimation { settings.settingsExpanded = true } }) {
+            if settings.isSnapped {
+                HStack(spacing: 12) {
+                    Image(systemName: settings.showHover ? "eye" : "eye.slash")
+
+                    Image(systemName: settings.isDimmed ? "moon.fill" : "sun.max")
+                        .foregroundStyle(settings.isDimmed ? .primary : .secondary)
+
+                    Divider().frame(height: 8)
+
+                    if settings.curvatureRadius == 0 {
+                        FlatButtonIcon()
+                            .frame(width: 24, height: 24)
+                    } else if settings.curvatureRadius > 1000.0 {
+                        CurvedButtonIcon()
+                            .frame(width: 24, height: 24)
+                    } else {
+                        CurviestButtonIcon()
+                            .frame(width: 24, height: 24)
+                    }
+                }
+                .padding(.horizontal, 16)
+                .padding(.vertical, 16)
+            } else {
+                VStack(spacing: 12) {
+                    Image(systemName: settings.showHover ? "eye" : "eye.slash")
+
+                    Image(systemName: settings.isDimmed ? "moon.fill" : "sun.max")
+                        .foregroundStyle(settings.isDimmed ? .primary : .secondary)
+
+                    Divider().frame(height: 8)
+
+                    if settings.curvatureRadius == 0 {
+                        FlatButtonIcon()
+                            .frame(width: 24, height: 24)
+                    } else if settings.curvatureRadius > 1000.0 {
+                        CurvedButtonIcon()
+                            .frame(width: 24, height: 24)
+                    } else {
+                        CurviestButtonIcon()
+                            .frame(width: 24, height: 24)
+                    }
+                }
+                .padding(.horizontal, 16)
+                .padding(.vertical, 16)
+            }
+        }
+        .buttonStyle(.plain)
+        .glassBackgroundEffect()
+    }
+
+    // MARK: Expanded
+
+    private var expandedPanel: some View {
+        VStack(spacing: 16) {
+            // Input type and dim controls
+            @Bindable var settings = self.settings
+
+            Text("").font(.title).padding(8)
+
+            HStack() {
+                Spacer()
+                Image(systemName: "eye.slash")
+
+                Toggle(isOn: $settings.showHover) {
+                }
+                .labelsHidden()
+                .tint(.secondary)
+
+                Image(systemName: "eye")
+                Spacer()
+
+                Spacer()
+                Image(systemName: "sun.max")
+
+                Toggle(isOn: $settings.isDimmed) {
+                }
+                .labelsHidden()
+                .tint(.secondary)
+
+                Image(systemName: "moon.fill")
+                Spacer()
+            }
+
+            // Curvature slider
+            VStack(spacing: 4) {
+                Text("\(curvatureLabel)")
+                    .font(.caption)
+
+                HStack() {
+                    FlatButtonIcon()
+                        .frame(width: 24, height: 24)
+
+                    Slider(value: $curvatureSlider, in: 0...1) {
+                    } currentValueLabel: {
+                        Text("\(curvatureLabel)")
+                    } ticks: {
+                        SliderTickContentForEach(Self.curvatureStepsSliderValue, id: \.self) { value in
+                            SliderTick(value)
+                        }
+                    }
+                    .onAppear {
+                        let curvature = settings.curvatureRadius
+                        if curvature > 0 {
+                            curvatureSlider = 1.0 - (curvature - Self.minimumCurvatureRadius)
+                            / (Self.maximumCurvatureRadius - Self.minimumCurvatureRadius)
+                        } else {
+                            curvatureSlider = 0.0
+                        }
+                    }
+                    .onChange(of: curvatureSlider) {
+                        let clamped = max(0.0, min(1.0, curvatureSlider))
+                        if clamped == 0 {
+                            settings.curvatureRadius = 0
+                        } else {
+                            let radius = roundf(curvatureSlider * Self.minimumCurvatureRadius
+                                                + (1.0 - curvatureSlider) * Self.maximumCurvatureRadius)
+                            settings.curvatureRadius = radius
+                        }
+                        SDL_VisionOS_SendCurvatureChanged(settings.curvatureRadius)
+                    }
+
+                    CurviestButtonIcon()
+                        .frame(width: 24, height: 24)
+                }
+            }
+        }
+        .padding(20)
+        .frame(width: 340)
+        .overlay(alignment: .topLeading) {
+            // X button
+            Button(action: { withAnimation { settings.settingsExpanded = false } }) {
+                Image(systemName: "xmark")
+                    .font(.system(size: 15, weight: .bold, design: .rounded))
+                    .padding(8)
+                    .contentShape(Circle())
+            }
+            .buttonStyle(.bordered)
+            .buttonBorderShape(.circle)
+            .padding(20)
+        }
+        .glassBackgroundEffect()
+    }
+}

+ 350 - 0
src/video/uikit/SDL_CurvedContentView.swift

@@ -0,0 +1,350 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+import SwiftUI
+import RealityKit
+import GameController
+
+/// SwiftUI view that presents SDL content on a curved RealityKit mesh
+/// inside a UIHostingController
+internal struct SDL_CurvedContentView: View {
+    /// Helper object used to manage the mesh and texture of the curved UI.
+    let helper: SDL_RealityKitHelper
+
+    /// Settings object provided by the caller which determines the UI state.
+    let settings: SDL_CurvedContentSettings
+
+    /// Information about the window snap status
+    @Environment(\.surfaceSnappingInfo) private var snappedStatus
+
+    /// Closure which is called when the content is ready to present.
+    let onContentReady: @MainActor () -> Void
+
+    /// RealityKit entity which is created on appear, to be populated by the curved UI content.
+    @State private var curvedUIEntity: ModelEntity! = nil
+
+    /// Curved UI material which is created on appear.  Holds the compiled shader and material parameters.
+    @State private var curvedUIMaterial: CurvedUIMaterial! = nil
+
+    /// Converts SwiftUI points to meters (RealityKit coordinates)
+    ///
+    /// - Note: This conversion varies depending on the physical distance between the window and the user.
+    @PhysicalMetric(from: .meters) private var pointsPerMeter: Float = 1
+
+    /// Inverse of ``pointsPerMeter``.
+    var metersPerPoint: Float { 1.0 / pointsPerMeter }
+
+    /// The cursor color which should be passed to `curvedUIMaterial`
+    @State private var cursorColor: UIColor = .lightGray
+
+    /// The cursor color on interact (pinch/drag/click) which should be passed to `curvedUIMaterial`
+    @State private var cursorColorOnInteract: UIColor = .systemCyan
+
+    /// Whether to show the cursor overlay on the mesh surface.
+    private var showCursor: Bool {
+        return !mouseInputEnabled && settings.showHover
+    }
+
+    /// Whether mouse input is enabled.  When this is the case, the collision shape for indirect input should be disabled.
+    private var mouseInputEnabled: Bool {
+        return settings.inputType == .pointer
+    }
+
+    private var shouldPopulateCollisionShape: Bool {
+        return curvedUIEntity != nil && helper.collisionShape != nil && !mouseInputEnabled
+    }
+
+    /// Value use to animate the screen radius
+    @State private var animatedScreenRadius: Float = 1010
+
+    let SDL_EVENT_FINGER_DOWN: UInt32 = 0x700
+    let SDL_EVENT_FINGER_UP: UInt32 = 0x701
+    let SDL_EVENT_FINGER_MOTION: UInt32 = 0x702
+    let SDL_EVENT_FINGER_CANCELED: UInt32 = 0x703
+    private(set) static var last_fingerID: UInt64 = 0
+    private(set) static var fingers: [SpatialEventCollection.Event.ID: UInt64] = [:]
+
+    private func sendTouchEvent(event: SpatialEventCollection.Event, proxy: GeometryProxy3D) {
+        var fingerID: UInt64
+        var eventType: UInt32
+        if let value = Self.fingers[event.id] {
+            fingerID = value
+            if event.phase == SpatialEventCollection.Event.Phase.active {
+                eventType = SDL_EVENT_FINGER_MOTION
+            } else if event.phase == SpatialEventCollection.Event.Phase.ended {
+                eventType = SDL_EVENT_FINGER_UP
+                Self.fingers.removeValue(forKey: event.id)
+            } else {
+                eventType = SDL_EVENT_FINGER_CANCELED
+                Self.fingers.removeValue(forKey: event.id)
+            }
+        } else if event.phase == SpatialEventCollection.Event.Phase.active {
+            Self.last_fingerID += 1
+            fingerID = Self.last_fingerID
+            Self.fingers[event.id] = fingerID
+            eventType = SDL_EVENT_FINGER_DOWN
+        } else {
+            return
+        }
+
+        let loc = Point3D(x: event.location3D.x - proxy.size.width / 2,
+                          y: event.location3D.y - proxy.size.height / 2,
+                          z: event.location3D.z - proxy.size.depth / 2)
+        let meshPos = SIMD3<Float>(Float(loc.x) * metersPerPoint,
+                                   Float(loc.y) * metersPerPoint,
+                                   Float(loc.z) * metersPerPoint)
+        let uv = helper.meshGeometry.normalizedUV(fromMeshPosition: meshPos)
+
+        SDL_VisionOS_SendTouch(event.timestamp, fingerID, eventType, uv.x, uv.y)
+    }
+
+    var body: some View {
+        GeometryReader3D { proxy in
+            realityContent(proxy)
+                .glassBackgroundEffect(displayMode: .never)
+        }
+    }
+
+    private func realityContent(_ proxy: GeometryProxy3D) -> some View {
+        RealityView { content in
+            //NSLog("SDL_CurvedContentView: RealityView setup")
+
+            let frameInMeters: BoundingBox = content.convert(proxy.frame(in: .local), from: .local, to: .scene)
+            helper.updateMeshSize(width: frameInMeters.extents.x, height: frameInMeters.extents.y)
+
+            // Compile curved UI shader (may take a while)
+            let material = try! await CurvedUIMaterial()
+            self.curvedUIMaterial = material
+
+            // Create RealityKit Entity to host the curved UI content
+            let mesh = try! await MeshResource(from: helper.lowLevelMesh)
+            let entity = ModelEntity(mesh: mesh, materials: [material.shaderGraphMaterial])
+
+            // Add InputTargetComponent to the mesh to accept input.
+            entity.components.set(InputTargetComponent(allowedInputTypes: .all))
+
+            // Add HoverEffectComponent to visualize the gaze target
+            let shaderInputs = HoverEffectComponent.ShaderHoverEffectInputs.default
+            let hoverEffect = HoverEffectComponent.HoverEffect.shader(shaderInputs)
+            let hoverEffectComponent = HoverEffectComponent(hoverEffect)
+            entity.components.set(hoverEffectComponent)
+
+            // Increase the responsiveness of the hover effect
+            RenderRefreshSystem.registerSystem()
+            entity.components.set(RenderRefreshComponent(
+                componentToRefresh: hoverEffectComponent
+            ))
+
+            self.curvedUIEntity = entity
+            content.add(entity)
+
+            // Call the user-provided contentReady closure.
+            onContentReady()
+        } update: { content in
+            let frameInMeters: BoundingBox = content.convert(proxy.frame(in: .local), from: .local, to: .scene)
+            helper.updateMeshSize(width: frameInMeters.extents.x, height: frameInMeters.extents.y)
+
+            let frame = proxy.frame(in: .local)
+            SDL_VisionOS_SendSizeChanged(Int(frame.size.width), Int(frame.size.height))
+        }
+        .overlay {
+            if mouseInputEnabled {
+                // This enables mouse motion events, but blocks hover location
+                Color.white
+                    .opacity(0.001)
+                    .pointerStyle(.shape(Circle(), size: .zero))
+            }
+        }
+        .gesture(
+            SpatialEventGesture()
+                .onChanged { events in
+                    guard curvedUIMaterial != nil else { return }
+
+                    if !mouseInputEnabled {
+                        curvedUIMaterial.isInteracting = true
+
+                        for event in events {
+                            if event.kind != .pointer {
+                                sendTouchEvent(event: event, proxy: proxy)
+                            } else {
+                                settings.inputType = .pointer
+                                settings.sceneState = .cinematic
+                            }
+                        }
+                    }
+                }
+                .onEnded { events in
+                    guard curvedUIMaterial != nil else { return }
+
+                    if !mouseInputEnabled {
+                        for event in events {
+                            if event.kind != .pointer {
+                                sendTouchEvent(event: event, proxy: proxy)
+                            }
+                        }
+                    } else {
+                        for event in events {
+                            if event.kind != .pointer {
+                                settings.inputType = .eyes
+                                settings.sceneState = .interactive
+                            }
+                        }
+                    }
+
+                    curvedUIMaterial.isInteracting = false
+                }
+        )
+        .onChange(of: sceneActivationOrObject(showCursor), initial: true) {
+            curvedUIMaterial?.showCursor = showCursor
+        }
+        .onChange(of: sceneActivationOrObject(cursorColor), initial: true) {
+            curvedUIMaterial?.cursorColor = cursorColor
+        }
+        .onChange(of: sceneActivationOrObject(cursorColorOnInteract), initial: true) {
+            curvedUIMaterial?.cursorColorOnInteract = cursorColorOnInteract
+        }
+        .onChange(of: sceneActivationOrObject(helper.meshGeometry), initial: true) {
+            guard curvedUIMaterial != nil else { return }
+            let geometry = helper.meshGeometry
+            curvedUIMaterial.cursorSize = geometry.height * 0.01
+        }
+        .onChange(of: sceneActivationOrObject(helper.textureResource), initial: true) {
+            if let textureResource = helper.textureResource {
+                curvedUIMaterial?.gameTexture = textureResource
+            }
+        }
+        .onChange(of: sceneActivationOrObject(curvedUIMaterial), initial: true) {
+            // Update the materials array of the entity with the updated material parameters.
+            if let curvedUIMaterial, let curvedUIEntity {
+                curvedUIEntity.model!.materials = [curvedUIMaterial.shaderGraphMaterial]
+            }
+        }
+        .onChange(of: settings.inputType, initial: true) { oldInputType, inputType in
+            if inputType == .pointer {
+                SDL_VisionOS_SendPointerMode(true)
+            } else {
+                SDL_VisionOS_SendPointerMode(false)
+            }
+        }
+        .onChange(of: settings.curvatureRadius, initial: true) { oldRadius, curvatureRadius in
+            if oldRadius != curvatureRadius {
+                withAnimation(.smooth) {
+                    if curvatureRadius > 0 {
+                        animatedScreenRadius = curvatureRadius / 1000
+                    } else {
+                        animatedScreenRadius = AnimatedCurveRadiusModifier.assumedFlatThreshold + 0.01
+                    }
+                }
+            } else {
+                if curvatureRadius > 0 {
+                    animatedScreenRadius = curvatureRadius / 1000
+                } else {
+                    animatedScreenRadius = AnimatedCurveRadiusModifier.assumedFlatThreshold + 0.01
+                }
+            }
+        }
+        .modifier(AnimatedCurveRadiusModifier(helper: helper, curveRadius: animatedScreenRadius))
+        .onChange(of: sceneActivationOrObject(shouldPopulateCollisionShape ? helper.collisionShape : nil)) {
+            guard let curvedUIEntity else { return }
+            if let shape = helper.collisionShape, shouldPopulateCollisionShape {
+                curvedUIEntity.components.set(CollisionComponent(shapes: [shape]))
+            } else {
+                curvedUIEntity.components.set(CollisionComponent(shapes: []))
+            }
+        }
+        .onChange(of: snappedStatus) {
+            settings.isSnapped = snappedStatus.isSnapped
+            helper.updateSnappedStatus(snapped: snappedStatus.isSnapped)
+        }
+        .preferredSurroundingsEffect(settings.isDimmed ? .dark : nil)
+        .frame(depth: 0)
+        .ignoresSafeArea()
+        .persistentSystemOverlays(settings.sceneState == .cinematic ? .hidden : .automatic)
+        .handlesGameControllerEvents(matching: .gamepad)
+    }
+}
+
+// MARK: Animating the curve radius
+
+@Animatable
+private struct AnimatedCurveRadiusModifier: @MainActor ViewModifier {
+    /// Curvature radius beyond which we assume it is flat.
+    static let assumedFlatThreshold: Float = 30.0
+
+    /// Helper object to modify
+    let helper: SDL_RealityKitHelper
+
+    /// Curve radius > `assumedFlatThreshold` meters is assumed to be flat.
+    var curveRadius: Float
+
+    func body(content: Content) -> some View {
+        content.onChange(of: curveRadius, initial: true) {
+            if curveRadius > 10 {
+                helper.updateMeshCurvature(curvatureRadius: 0)
+            } else {
+                helper.updateMeshCurvature(curvatureRadius: curveRadius)
+            }
+        }
+    }
+}
+
+// MARK: Bridging SwiftUI and RealityKit
+
+private extension SDL_CurvedContentView {
+    private struct Box<T: Equatable>: Equatable {
+        var sceneActivation: Bool
+        var value: T
+    }
+
+    /// Convenience function which triggers an `onChange` event either when `object` changes, or when
+    /// ``curvedUIMaterial`` finishes compiling.
+    func sceneActivationOrObject<T: Equatable>(_ object: T) -> some Equatable {
+        return Box(sceneActivation: self.curvedUIMaterial != nil && self.curvedUIEntity != nil, value: object)
+    }
+}
+
+// MARK: Per-frame component refresh
+
+/// Attach this component to an entity to reset a RealityKit component every rendering frame.
+/// This can be used to disable system-default interpolation on any component that applies it.
+///
+/// Example — to reset a platform-specific component every frame:
+///     entity.components.set(RenderRefreshComponent(
+///         componentToRefresh: CustomComponent()
+///     ))
+private struct RenderRefreshComponent: TransientComponent {
+    var componentToRefresh: (any Component)?
+}
+
+private struct RenderRefreshSystem: System {
+    static let query = EntityQuery(where: .has(RenderRefreshComponent.self))
+    init(scene: RealityKit.Scene) {
+        RenderRefreshComponent.registerComponent()
+    }
+
+    func update(context: SceneUpdateContext) {
+        for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
+            guard let refresh = entity.components[RenderRefreshComponent.self],
+                  let component = refresh.componentToRefresh else { continue }
+            entity.components.remove(type(of: component))
+            entity.components.set(component)
+        }
+    }
+}

+ 544 - 0
src/video/uikit/SDL_CurvedUIShader.swift

@@ -0,0 +1,544 @@
+//
+//  SDL_CurvedUIShader.swift
+//  SDL3
+//
+//  Created by Adrian Biagioli on 4/21/26.
+//
+
+import Foundation
+import RealityKit
+
+/// A MaterialX curved UI shader USDA.  This is loaded on launch into a ShaderGraphMaterial.
+///
+/// You can inspect this shader yourself in Reality Composer Pro.
+/// To do this, copy this string and save it as a .usda file.
+/// Then, add it to a Reality Composer Pro object.
+private let curvedUIShaderUSDA = """
+#usda 1.0
+(
+    customLayerData = {
+        string creator = "Reality Composer Pro Version 2.0 (494.100.6)"
+    }
+    defaultPrim = "Root"
+    metersPerUnit = 1
+    upAxis = "Y"
+)
+
+def Xform "Root"
+{
+    def Material "CurvedUIMaterial"
+    {
+        reorder nameChildren = ["DefaultSurfaceShader", "UnlitSurface", "TextureCoordinates", "Position", "Image2D", "Group2", "Group4", "CursorPositionOnScreen", "SelectCursorColor", "SelectCursorOpacity", "GameTextureRGB", "NormalizedDistance", "Dot", "Group", "Dot_1", "DiscardCursorOutsideRange", "MixCursorOverGame", "HideCursorIfDisabled"]
+        color3f inputs:CursorColor = (0, 0.87658346, 1) (
+            colorSpace = "lin_srgb"
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-374.2671, 402.7502)
+                    int stackingOrderInSubgraph = 1955
+                }
+            }
+        )
+        color3f inputs:CursorColorOnInteract = (0.016926037, 0, 0.7703071) (
+            colorSpace = "lin_srgb"
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-408.82837, 336.09396)
+                    int stackingOrderInSubgraph = 2017
+                }
+            }
+        )
+        float inputs:CursorEdgeThreshold = 0.9 (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-706.12756, 582.3273)
+                    int stackingOrderInSubgraph = 1951
+                }
+            }
+        )
+        float inputs:CursorOpacityEdge = 0.7 (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-704.3221, 648.0528)
+                    int stackingOrderInSubgraph = 1953
+                }
+            }
+        )
+        float inputs:CursorOpacityInside = 0.4 (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-701.167, 710.96765)
+                    int stackingOrderInSubgraph = 1955
+                }
+            }
+        )
+        float inputs:CursorSize = 0.003 (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-1204.8192, 509.2949)
+                    int stackingOrderInSubgraph = 2015
+                }
+            }
+        )
+        asset inputs:GameTexture (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-1270.7656, -315.35458)
+                    int stackingOrderInSubgraph = 1834
+                }
+            }
+        )
+        bool inputs:IsInteracting = 0 (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-373.38513, 263.61777)
+                    int stackingOrderInSubgraph = 1955
+                }
+            }
+        )
+        bool inputs:ShowCursor = 1 (
+            customData = {
+                dictionary realitykit = {
+                    float2 positionInSubgraph = (-1721.0664, 367.89142)
+                    int stackingOrderInSubgraph = 2360
+                }
+            }
+        )
+        token outputs:mtlx:surface.connect = </Root/CurvedUIMaterial/UnlitSurface.outputs:out>
+        token outputs:realitykit:vertex
+        token outputs:surface.connect = </Root/CurvedUIMaterial/DefaultSurfaceShader.outputs:surface>
+        float2 ui:nodegraph:realitykit:subgraphOutputs:pos = (612.1894, 109.99387)
+        int ui:nodegraph:realitykit:subgraphOutputs:stackingOrder = 1993
+
+        def Shader "DefaultSurfaceShader" (
+            active = false
+        )
+        {
+            uniform token info:id = "UsdPreviewSurface"
+            color3f inputs:diffuseColor = (1, 1, 1)
+            float inputs:roughness = 0.75
+            token outputs:surface
+        }
+
+        def Shader "UnlitSurface"
+        {
+            uniform token info:id = "ND_realitykit_unlit_surfaceshader"
+            bool inputs:applyPostProcessToneMap = 0
+            color3f inputs:color.connect = </Root/CurvedUIMaterial/MixCursorOverGame.outputs:out>
+            bool inputs:hasPremultipliedAlpha
+            float inputs:opacity
+            float inputs:opacityThreshold
+            token outputs:out
+            float2 ui:nodegraph:node:pos = (368.7634, 58.4275)
+            int ui:nodegraph:node:stackingOrder = 1993
+        }
+
+        def Shader "TextureCoordinates"
+        {
+            uniform token info:id = "ND_texcoord_vector2"
+            float2 outputs:out
+            float2 ui:nodegraph:node:pos = (-1292.3005, -120.02362)
+            int ui:nodegraph:node:stackingOrder = 1834
+        }
+
+        def Shader "Position"
+        {
+            uniform token info:id = "ND_position_vector3"
+            string inputs:space = "world"
+            float3 outputs:out
+            float2 ui:nodegraph:node:pos = (-1205.6492, 445.2142)
+            int ui:nodegraph:node:stackingOrder = 2314
+        }
+
+        def Shader "Image2D"
+        {
+            uniform token info:id = "ND_RealityKitTexture2D_color4"
+            float inputs:bias
+            string inputs:border_color
+            float inputs:dynamic_min_lod_clamp
+            asset inputs:file.connect = </Root/CurvedUIMaterial.inputs:GameTexture>
+            bool inputs:no_flip_v = 1
+            int2 inputs:offset
+            float2 inputs:texcoord.connect = </Root/CurvedUIMaterial/TextureCoordinates.outputs:out>
+            string inputs:u_wrap_mode
+            string inputs:v_wrap_mode
+            color4f outputs:out
+            float2 ui:nodegraph:node:pos = (-1023.8389, -194.1174)
+            int ui:nodegraph:node:stackingOrder = 1834
+            string[] ui:nodegraph:realitykit:node:attributesShowingChildren = ["inputs:no_flip_v"]
+        }
+
+        def Scope "Group2" (
+            kind = "group"
+        )
+        {
+            string ui:group:annotation = "Apply final color to UnlitMaterial"
+            string ui:group:annotationDescription = ""
+            string[] ui:group:members = ["p:UnlitSurface", "o:_subgraphOutput"]
+        }
+
+        def Scope "Group4" (
+            kind = "group"
+        )
+        {
+            string ui:group:annotation = "Sample game texture"
+            string ui:group:annotationDescription = ""
+            string[] ui:group:members = ["i:inputs:GameTexture", "p:Image2D", "p:TextureCoordinates"]
+        }
+
+        def Shader "SelectCursorColor"
+        {
+            uniform token info:id = "ND_ifequal_color3B"
+            color3f inputs:in1.connect = </Root/CurvedUIMaterial.inputs:CursorColorOnInteract>
+            color3f inputs:in2.connect = </Root/CurvedUIMaterial.inputs:CursorColor>
+            bool inputs:value1.connect = </Root/CurvedUIMaterial.inputs:IsInteracting>
+            bool inputs:value2 = 1
+            color3f outputs:out
+            float2 ui:nodegraph:node:pos = (-175.6293, 330.2353)
+            int ui:nodegraph:node:stackingOrder = 1955
+        }
+
+        def Shader "SelectCursorOpacity"
+        {
+            uniform token info:id = "ND_ifgreater_float"
+            float inputs:in1.connect = </Root/CurvedUIMaterial.inputs:CursorOpacityEdge>
+            float inputs:in2.connect = </Root/CurvedUIMaterial.inputs:CursorOpacityInside>
+            float inputs:value1.connect = </Root/CurvedUIMaterial/Dot.outputs:out>
+            float inputs:value2.connect = </Root/CurvedUIMaterial.inputs:CursorEdgeThreshold>
+            float outputs:out
+            float2 ui:nodegraph:node:pos = (-463.96164, 578.08826)
+            int ui:nodegraph:node:stackingOrder = 1853
+        }
+
+        def Shader "GameTextureRGB"
+        {
+            uniform token info:id = "ND_swizzle_color4_color3"
+            string inputs:channels = "rgb"
+            color4f inputs:in.connect = </Root/CurvedUIMaterial/Image2D.outputs:out>
+            color3f outputs:out
+            float2 ui:nodegraph:node:pos = (-732.1035, -11.733684)
+            int ui:nodegraph:node:stackingOrder = 1834
+        }
+
+        def NodeGraph "NormalizedDistance"
+        {
+            float3 inputs:A (
+                customData = {
+                    dictionary realitykit = {
+                        float2 positionInSubgraph = (79.30469, 187.10547)
+                        int stackingOrderInSubgraph = 1406
+                    }
+                }
+            )
+            float3 inputs:A.connect = </Root/CurvedUIMaterial/HideCursorIfDisabled.outputs:out>
+            float3 inputs:B (
+                customData = {
+                    dictionary realitykit = {
+                        float2 positionInSubgraph = (79.234375, 270.22266)
+                        int stackingOrderInSubgraph = 1408
+                    }
+                }
+            )
+            float3 inputs:B.connect = </Root/CurvedUIMaterial/Position.outputs:out>
+            float inputs:Radius (
+                customData = {
+                    dictionary realitykit = {
+                        float2 positionInSubgraph = (306.85156, 333.83984)
+                        int stackingOrderInSubgraph = 1406
+                    }
+                }
+            )
+            float inputs:Radius.connect = </Root/CurvedUIMaterial.inputs:CursorSize>
+            float outputs:ZeroToOneDistance (
+                customData = {
+                    dictionary realitykit = {
+                        float2 positionInSubgraph = (444.625, 223)
+                        int stackingOrderInSubgraph = 1409
+                    }
+                }
+            )
+            float outputs:ZeroToOneDistance.connect = </Root/CurvedUIMaterial/NormalizedDistance/Remap.outputs:out>
+            float2 ui:nodegraph:node:pos = (-998.9227, 417.7417)
+            int ui:nodegraph:node:stackingOrder = 2010
+            string[] ui:nodegraph:realitykit:node:attributesShowingChildren = ["outputs:Clamp_out", "inputs:A"]
+            float2 ui:nodegraph:realitykit:subgraphOutputs:pos = (711.2656, 366.07812)
+            int ui:nodegraph:realitykit:subgraphOutputs:stackingOrder = 1409
+
+            def Shader "Remap"
+            {
+                uniform token info:id = "ND_remap_float"
+                float inputs:in.connect = </Root/CurvedUIMaterial/NormalizedDistance/MTLDistance.outputs:out>
+                float inputs:inhigh.connect = </Root/CurvedUIMaterial/NormalizedDistance.inputs:Radius>
+                float inputs:inlow = 0
+                float inputs:outhigh = 1
+                float inputs:outlow = 0
+                float outputs:out
+                float2 ui:nodegraph:node:pos = (503, 318.58984)
+                int ui:nodegraph:node:stackingOrder = 1407
+            }
+
+            def Shader "MTLDistance"
+            {
+                uniform token info:id = "ND_MTL_distance_vector3_float"
+                float3 inputs:x.connect = </Root/CurvedUIMaterial/NormalizedDistance.inputs:A>
+                float3 inputs:y.connect = </Root/CurvedUIMaterial/NormalizedDistance.inputs:B>
+                float outputs:out
+                float2 ui:nodegraph:node:pos = (304, 186.67969)
+                int ui:nodegraph:node:stackingOrder = 1402
+            }
+        }
+
+        def Shader "Dot"
+        {
+            uniform token info:id = "ND_dot_float"
+            float inputs:in.connect = </Root/CurvedUIMaterial/NormalizedDistance.outputs:ZeroToOneDistance>
+            float outputs:out
+            float2 ui:nodegraph:node:pos = (-626.7584, 475.93542)
+            int ui:nodegraph:node:stackingOrder = 1735
+        }
+
+        def Scope "Group" (
+            kind = "group"
+        )
+        {
+            string ui:group:annotation = "Select cursor color and opacity"
+            string ui:group:annotationDescription = "The color is selected depending if the user is interacting (click/tap/pinch/drag).  The opacity is selected via the distance between this fragment's position and the cursor position"
+            string[] ui:group:members = ["i:inputs:IsInteracting", "p:Dot_1", "p:DiscardCursorOutsideRange", "i:inputs:CursorColorOnInteract", "p:SelectCursorColor", "i:inputs:CursorColor", "p:Dot", "i:inputs:CursorOpacityEdge", "i:inputs:CursorOpacityInside", "p:SelectCursorOpacity", "i:inputs:CursorEdgeThreshold"]
+        }
+
+        def Shader "Dot_1"
+        {
+            uniform token info:id = "ND_dot_float"
+            float inputs:in.connect = </Root/CurvedUIMaterial/Dot.outputs:out>
+            float outputs:out
+            float2 ui:nodegraph:node:pos = (-370.1385, 475.2281)
+            int ui:nodegraph:node:stackingOrder = 1851
+        }
+
+        def Shader "DiscardCursorOutsideRange"
+        {
+            uniform token info:id = "ND_ifgreater_float"
+            float inputs:in1 = 0
+            float inputs:in2.connect = </Root/CurvedUIMaterial/SelectCursorOpacity.outputs:out>
+            float inputs:value1.connect = </Root/CurvedUIMaterial/Dot_1.outputs:out>
+            float inputs:value2 = 1
+            float outputs:out
+            float2 ui:nodegraph:node:pos = (-192.05971, 600.1504)
+            int ui:nodegraph:node:stackingOrder = 1966
+        }
+
+        def Shader "MixCursorOverGame"
+        {
+            uniform token info:id = "ND_mix_color3"
+            color3f inputs:bg.connect = </Root/CurvedUIMaterial/GameTextureRGB.outputs:out>
+            color3f inputs:fg.connect = </Root/CurvedUIMaterial/SelectCursorColor.outputs:out>
+            float inputs:mix.connect = </Root/CurvedUIMaterial/DiscardCursorOutsideRange.outputs:out>
+            color3f outputs:out
+            float2 ui:nodegraph:node:pos = (90.70218, -17.587646)
+            int ui:nodegraph:node:stackingOrder = 1973
+        }
+
+        def Shader "HideCursorIfDisabled"
+        {
+            uniform token info:id = "ND_ifequal_vector3B"
+            float3 inputs:in1.connect = </Root/CurvedUIMaterial/HoverState.outputs:position>
+            float3 inputs:in2 = (999999, 999999, 999999)
+            bool inputs:value1.connect = </Root/CurvedUIMaterial/And.outputs:out>
+            bool inputs:value2 = 1
+            bool inputs:value2.connect = None
+            float3 outputs:out
+            float2 ui:nodegraph:node:pos = (-1281.8472, 322.0585)
+            int ui:nodegraph:node:stackingOrder = 2361
+        }
+
+        def Shader "HoverState"
+        {
+            uniform token info:id = "ND_realitykit_hover_state"
+            float outputs:intensity
+            bool outputs:isActive
+            float3 outputs:position
+            float outputs:timeSinceHoverStart
+            float2 ui:nodegraph:node:pos = (-1730.769, 258.70575)
+            int ui:nodegraph:node:stackingOrder = 2360
+            string[] ui:nodegraph:realitykit:node:attributesShowingChildren = ["outputs:position"]
+        }
+
+        def Shader "And"
+        {
+            uniform token info:id = "ND_realitykit_logical_and"
+            bool inputs:in1.connect = </Root/CurvedUIMaterial/HoverState.outputs:isActive>
+            bool inputs:in2.connect = </Root/CurvedUIMaterial.inputs:ShowCursor>
+            bool outputs:out
+            float2 ui:nodegraph:node:pos = (-1571.7467, 334.56076)
+            int ui:nodegraph:node:stackingOrder = 2360
+        }
+    }
+}
+
+"""
+
+/// A wrapper object around a RealityKit `ShaderGraphMaterial`, but specific to the SDL curved UI shader.
+///
+/// This struct provides material parameters that pass through to the `ShaderGraphMaterial`.
+@MainActor
+struct CurvedUIMaterial: @MainActor Equatable {
+    /// A cached ShaderGraphMaterial, populated with a prototype ShaderGraphMaterial.
+    ///
+    /// On subsequent loads, the alread-loaded material is used directly.
+    @MainActor private static var cachedShaderGraph: ShaderGraphMaterial?
+    
+    /// The ShaderGraphMaterial which should be used to populate the curved UI Entity's `ModelComponent`.
+    ///
+    /// - Note: ShaderGraphMaterial is a value type (`struct`), so you must re-query this value after changing any parameters.
+    private(set) var shaderGraphMaterial: ShaderGraphMaterial
+    
+    /// Initializes the curved UI material.
+    ///
+    /// If the shader needs to compile (first launch), then it compiles before returning.
+    /// If the shader is already compiled, returns immediately.
+    @MainActor
+    init() async throws {
+        if let cachedShaderGraph = Self.cachedShaderGraph {
+            self.shaderGraphMaterial = cachedShaderGraph
+        } else {
+            let result = try await ShaderGraphMaterial(
+                named: "/Root/CurvedUIMaterial",
+                from: Data(curvedUIShaderUSDA.utf8)
+            )
+            Self.cachedShaderGraph = result
+            self.shaderGraphMaterial = result
+        }
+    }
+    
+    /// The texture containing SDL content.
+    var gameTexture: TextureResource! {
+        get { shaderGraphMaterial.getParameter(.gameTexture) }
+        set { try! shaderGraphMaterial.setParameter(.gameTexture, value: newValue) }
+    }
+    
+    /// Color of the cursor overlay when not actively interacting.
+    var cursorColor: UIColor! {
+        get { shaderGraphMaterial.getParameter(.cursorColor) }
+        set { try! shaderGraphMaterial.setParameter(.cursorColor, value: newValue) }
+    }
+    
+    /// Color of the cursor when interacting (click/tap/pinch/drag)
+    var cursorColorOnInteract: UIColor! {
+        get { shaderGraphMaterial.getParameter(.cursorColorOnInteract) }
+        set { try! shaderGraphMaterial.setParameter(.cursorColorOnInteract, value: newValue) }
+    }
+    
+    /// The size of the cursor in meters.
+    var cursorSize: Float! {
+        get { shaderGraphMaterial.getParameter(.cursorSize) }
+        set { try! shaderGraphMaterial.setParameter(.cursorSize, value: newValue) }
+    }
+    
+    /// Whether to show the cursor overlay on the mesh surface.
+    var showCursor: Bool! {
+        get { shaderGraphMaterial.getParameter(.showCursor) }
+        set { try! shaderGraphMaterial.setParameter(.showCursor, value: newValue) }
+    }
+
+    /// True if the user is actively interacting with the scene (e.g. click, tap, pinch, or drag).
+    var isInteracting: Bool! {
+        get { shaderGraphMaterial.getParameter(.isInteracting) }
+        set { try! shaderGraphMaterial.setParameter(.isInteracting, value: newValue) }
+    }
+    
+    static func == (lhs: CurvedUIMaterial, rhs: CurvedUIMaterial) -> Bool {
+        return lhs.gameTexture == rhs.gameTexture
+            && lhs.cursorColor == rhs.cursorColor
+            && lhs.cursorColorOnInteract == rhs.cursorColorOnInteract
+            && lhs.cursorSize == rhs.cursorSize
+            && lhs.showCursor == rhs.showCursor
+            && lhs.isInteracting == rhs.isInteracting
+    }
+}
+
+@MainActor
+private extension MaterialParameters.Handle {
+    static let gameTexture = ShaderGraphMaterial.parameterHandle(name: "GameTexture")
+    static let cursorColor = ShaderGraphMaterial.parameterHandle(name: "CursorColor")
+    static let cursorColorOnInteract = ShaderGraphMaterial.parameterHandle(name: "CursorColorOnInteract")
+    static let cursorSize = ShaderGraphMaterial.parameterHandle(name: "CursorSize")
+    static let showCursor = ShaderGraphMaterial.parameterHandle(name: "ShowCursor")
+    static let isInteracting = ShaderGraphMaterial.parameterHandle(name: "IsInteracting")
+}
+
+private extension ShaderGraphMaterial {
+    /// Convenience function to recover a typed shader parameter (without going through `MaterialParametres.Value` enum)
+    func getParameter<T>(_ handle: MaterialParameters.Handle, type: T.Type = T.self) -> T? {
+        guard let value = self.getParameter(handle: handle) else { return nil }
+        
+        switch (type.self, value) {
+        case (is MaterialParameters.Texture.Type, .texture(let v)): return (v as! T)
+        case (is TextureResource.Type, .texture(let v)): return (v.resource as! T)
+        case (is TextureResource.Type, .textureResource(let v)): return (v as! T)
+        case (is Float.Type, .float(let v)): return (v as! T)
+        case (is SIMD2<Float>.Type, .simd2Float(let v)): return (v as! T)
+        case (is SIMD3<Float>.Type, .simd3Float(let v)): return (v as! T)
+        case (is SIMD4<Float>.Type, .simd4Float(let v)): return (v as! T)
+        case (is UIColor.Type, .color(let v)): fallthrough
+        case (is CGColor.Type, .color(let v)):
+            // `is CGColor` works for both UIColor and CGColor
+            if type == CGColor.self {
+                return (v as! T)
+            } else if type == UIColor.self {
+                return (UIColor(cgColor: v) as! T)
+            } else {
+                preconditionFailure("Unknown Color type \(type)")
+            }
+        case (is float2x2.Type, .float2x2(let v)): return (v as! T)
+        case (is float3x3.Type, .float3x3(let v)): return (v as! T)
+        case (is float4x4.Type, .float4x4(let v)): return (v as! T)
+        case (is Bool.Type, .bool(let v)): return (v as! T)
+        case (is Int.Type, .int(let v)): return (Int(v) as! T)
+        case (is Int32.Type, .int(let v)): return (v as! T)
+        default:
+            preconditionFailure("Invalid type \(type) for handle with value \(value)")
+        }
+    }
+    
+    /// Convenience function to set a typed shader parameter (without going through `MaterialParametres.Value` enum)
+    mutating func setParameter<T>(_ handle: MaterialParameters.Handle, value: T!) throws {
+        guard let value else { preconditionFailure("can not clear a material parameter") }
+        switch type(of: value).self {
+        case is MaterialParameters.Texture.Type:
+            try self.setParameter(handle: handle, value: .texture(value as! MaterialParameters.Texture))
+        case is TextureResource.Type:
+            try self.setParameter(handle: handle, value: .textureResource(value as! TextureResource))
+        case is Float.Type:
+            try self.setParameter(handle: handle, value: .float(value as! Float))
+        case is SIMD2<Float>.Type:
+            try self.setParameter(handle: handle, value: .simd2Float(value as! SIMD2<Float>))
+        case is SIMD3<Float>.Type:
+            try self.setParameter(handle: handle, value: .simd3Float(value as! SIMD3<Float>))
+        case is SIMD4<Float>.Type:
+            try self.setParameter(handle: handle, value: .simd4Float(value as! SIMD4<Float>))
+        case is CGColor.Type: fallthrough
+        case is UIColor.Type:
+            // `is CGColor` works for both UIColor and CGColor
+            if T.self == UIColor.self {
+                try self.setParameter(handle: handle, value: .color(value as! UIColor))
+            } else if T.self == CGColor.self {
+                try self.setParameter(handle: handle, value: .color(value as! CGColor))
+            } else {
+                preconditionFailure("Unknown Color type \(type(of: value))")
+            }
+        case is float2x2.Type:
+            try self.setParameter(handle: handle, value: .float2x2(value as! float2x2))
+        case is float3x3.Type:
+            try self.setParameter(handle: handle, value: .float3x3(value as! float3x3))
+        case is float4x4.Type:
+            try self.setParameter(handle: handle, value: .float4x4(value as! float4x4))
+        case is Bool.Type:
+            try self.setParameter(handle: handle, value: .bool(value as! Bool))
+        case is Int.Type:
+            try self.setParameter(handle: handle, value: .int(Int32(value as! Int)))
+        case is Int32.Type:
+            try self.setParameter(handle: handle, value: .int(value as! Int32))
+        default:
+            preconditionFailure("Invalid type \(type(of: value))")
+        }
+    }
+}

+ 396 - 0
src/video/uikit/SDL_RealityKitHelper.swift

@@ -0,0 +1,396 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+import RealityKit
+import SwiftUI
+import Metal
+import MetalKit
+import simd
+
+/// Custom vertex format for the curved plane mesh.
+/// Matches the layout described to LowLevelMesh via vertexAttributes/vertexLayouts.
+private struct CurvedPlaneVertex {
+    var position: SIMD3<Float> = .zero
+    var normal: SIMD3<Float> = .zero
+    var uv: SIMD2<Float> = .zero
+
+    static var vertexAttributes: [LowLevelMesh.Attribute] {
+        [
+            .init(semantic: .position, format: .float3, offset: MemoryLayout<Self>.offset(of: \.position)!),
+            .init(semantic: .normal, format: .float3, offset: MemoryLayout<Self>.offset(of: \.normal)!),
+            .init(semantic: .uv0, format: .float2, offset: MemoryLayout<Self>.offset(of: \.uv)!)
+        ]
+    }
+
+    static var vertexLayouts: [LowLevelMesh.Layout] {
+        [.init(bufferIndex: 0, bufferStride: MemoryLayout<Self>.stride)]
+    }
+
+    static func descriptor(vertexCount: Int, indexCount: Int) -> LowLevelMesh.Descriptor {
+        var desc = LowLevelMesh.Descriptor()
+        desc.vertexAttributes = vertexAttributes
+        desc.vertexLayouts = vertexLayouts
+        desc.vertexCapacity = vertexCount
+        desc.indexCapacity = indexCount
+        desc.indexType = .uint32
+        return desc
+    }
+}
+
+/// Provides RealityKit functionality
+///
+/// Key responsibilities:
+/// - Generate curved mesh geometry procedurally using LowLevelMesh for fast updates
+/// - Update textures using LowLevelTexture for efficient Metal → RealityKit transfer
+/// - Asynchronously cooks a physics collision mesh of the curved UI to be used as an input target
+@MainActor
+@Observable
+internal class SDL_RealityKitHelper {
+    /// A collision shape which should be assigned to the same entity as ``lowLevelMesh``, for input targeting.
+    private(set) var collisionShape: ShapeResource? = nil
+    
+    /// The TextureResource object which should be assigned to an entity in the scene.
+    private(set) var textureResource: TextureResource? = nil
+    
+    /// The LowLevelMesh object which should be assigned to an entity in the scene, positioned at the origin.
+    ///
+    /// This mesh is auomatically updated when you change ``meshGeometry`` via ``updateMeshGeometry()``.
+    /// LowLevelMesh is a class (reference type) so you can add it to your Entity's `MeshResource` once at init time.
+    let lowLevelMesh: LowLevelMesh
+
+    /// Topology characteristics of the generated mesh.  This is fixed at initialization time.
+    let meshTopology: CurvedMeshTopology
+
+    /// The current generated mesh geometry.  Update this with ``updateMeshGeometry()``
+    private(set) var meshGeometry: CurvedMeshGeometry = CurvedMeshGeometry(width: 1, height: 1)
+    
+    /// An async task responsible for managing physics mesh cooking.
+    ///
+    /// This guarantees that at most one cooking operation is active at a time.
+    /// Cooking generally takes > 1 frame, so it's important that there is not an explosion of redundant work
+    /// if there is a burst of resize activity.
+    private var physicsCookingTask: Task<Void, Never>?
+    
+    /// ``collisionShape`` is up to date with this `CurvedMeshGeometry`.
+    private var lastCookedGeometry: CurvedMeshGeometry?
+
+    /// LowLevelTexture that backs ``textureResource``.
+    private var lowLevelTexture: LowLevelTexture?
+    
+    struct CurvedMeshTopology: Sendable, Equatable {
+        /// Number of horizontal segments to use to generate the mesh grid
+        var segmentsX: Int = 32
+        
+        /// Number of vertical segments to use to generate the mesh grid
+        var segmentsY: Int = 32
+        
+        /// Total number of vertices required to generate a mesh with this topology
+        var vertexCount: Int { (segmentsX + 1) * (segmentsY + 1) }
+        
+        /// Total size of the index buffer when generating a mesh with this topology
+        var indexCount: Int { segmentsX * segmentsY * 6 }
+    }
+    
+    struct CurvedMeshGeometry: Sendable, Equatable {
+        /// Width of the mesh in meters.
+        var width: Float
+
+        /// Height of the mesh in meters.
+        var height: Float
+
+        /// Radius of the mesh curvature in meters, or `nil` for a flat mesh.
+        var curvatureRadius: Float = 0
+        
+        /// The bounding box of the mesh
+        var bounds: BoundingBox = BoundingBox()
+    
+        /// Current snapped status
+        var snapped: Bool = false
+
+        /// Converts a 3D position on the mesh surface (in meters, relative to mesh center)
+        /// to normalized texture coordinates (0..1, 0..1).
+        func normalizedUV(fromMeshPosition position: SIMD3<Float>) -> SIMD2<Float> {
+            if curvatureRadius > 0 {
+                let halfWidth = bounds.extents.x / 2
+                
+                let theta = asinf(halfWidth / curvatureRadius)
+                let angle = asinf(position.x / curvatureRadius)
+                
+                let u = (angle / theta + 1) / 2
+                let v = (position.y / height) + 0.5
+                return SIMD2(u, v)
+            } else {
+                let u = (position.x / width) + 0.5
+                let v = (position.y / height) + 0.5
+                return SIMD2(u, v)
+            }
+        }
+    }
+
+    init(meshTopology: CurvedMeshTopology = CurvedMeshTopology(),
+         meshGeometry: CurvedMeshGeometry = CurvedMeshGeometry(width: 1, height: 1)) {
+        self.meshTopology = meshTopology
+        self.meshGeometry = CurvedMeshGeometry(width: -1, height: -1)
+        
+        let lowLevelMesh = try! meshTopology.generateMesh()
+
+        self.lowLevelMesh = lowLevelMesh
+        
+        updateMeshGeometry(meshGeometry)
+    }
+    
+    // MARK: - Mesh Generation (LowLevelMesh)
+    
+    func updateSnappedStatus(snapped: Bool) {
+        var geometry = self.meshGeometry
+        geometry.snapped = snapped
+        updateMeshGeometry(geometry)
+    }
+    
+    func updateMeshSize(width: Float, height: Float) {
+        var geometry = self.meshGeometry
+        geometry.width = width
+        geometry.height = height
+        updateMeshGeometry(geometry)
+    }
+    
+    func updateMeshCurvature(curvatureRadius: Float) {
+        var geometry = self.meshGeometry
+        geometry.curvatureRadius = curvatureRadius
+        updateMeshGeometry(geometry)
+    }
+
+    /// Writes vertex position/normal/uv data into the LowLevelMesh buffer.
+    /// This is the fast path — called on every size or curvature change without
+    /// recreating MeshResource or Entity.
+    func updateMeshGeometry(_ meshGeometry: CurvedMeshGeometry) {
+        if meshGeometry == self.meshGeometry {
+            return // nothing to do
+        }
+        
+        let width = meshGeometry.width
+        let height = meshGeometry.height
+        let curvatureRadius = meshGeometry.curvatureRadius
+        
+        let segmentsX = meshTopology.segmentsX
+        let segmentsY = meshTopology.segmentsY
+        let indexCount = meshTopology.indexCount
+        
+        var boundsMin = SIMD3(repeating: Float.infinity)
+        var boundsMax = SIMD3(repeating: -Float.infinity)
+        
+        lowLevelMesh.withUnsafeMutableBytes(bufferIndex: 0) { rawBytes in
+            let vertices = rawBytes.bindMemory(to: CurvedPlaneVertex.self)
+
+            if curvatureRadius > 0 {
+
+                // Apply cylindrical curve: Z varies with X to create wrap-around
+                var curve_positions: [SIMD3<Float>] = []
+                var curve_normals: [SIMD3<Float>] = []
+                let r = curvatureRadius
+                let arc_length = width / r
+                for x in 0...segmentsX {
+                    let u = Float(x) / Float(segmentsX)
+                    let angle = (u - 0.5) * arc_length
+                    let vec: SIMD3<Float> = simd_normalize([sin(angle), 0.0, cos(angle)])
+                    let pos: SIMD3<Float> = [vec.x, vec.y, 1.0 - vec.z] * r
+                    curve_positions.append(pos)
+
+                    // Normal points toward viewer for convex curve
+                    curve_normals.append(-vec)
+                }
+                let offsetZ = meshGeometry.snapped ? 0 : -curve_positions[0].z
+                
+                for y in 0...segmentsY {
+                    let v = Float(y) / Float(segmentsY) * 2 - 1
+                    let posY = v * height / 2
+
+                    for x in 0...segmentsX {
+                        let u = Float(x) / Float(segmentsX) * 2 - 1
+                        
+                        let position = curve_positions[x] + SIMD3<Float>(0, posY, offsetZ)
+                        let normal = curve_normals[x]
+
+                        let idx = y * (segmentsX + 1) + x
+                        vertices[idx].position = position
+                        vertices[idx].normal = normal
+                        vertices[idx].uv = SIMD2<Float>((u + 1) / 2, (v + 1) / 2)
+                        
+                        boundsMin = min(boundsMin, position)
+                        boundsMax = max(boundsMax, position)
+                    }
+                }
+            } else {
+                // Flat plane — same grid, z=0
+                for y in 0...segmentsY {
+                    let v = Float(y) / Float(segmentsY)
+                    let posY = (v - 0.5) * height
+
+                    for x in 0...segmentsX {
+                        let u = Float(x) / Float(segmentsX)
+                        let posX = (u - 0.5) * width
+
+                        let idx = y * (segmentsX + 1) + x
+                        let position = SIMD3<Float>(posX, posY, 0)
+                        vertices[idx].position = position
+                        vertices[idx].normal = SIMD3<Float>(0, 0, -1)
+                        vertices[idx].uv = SIMD2<Float>(u, v)
+                        
+                        boundsMin = min(boundsMin, position)
+                        boundsMax = max(boundsMax, position)
+                    }
+                }
+            }
+        }
+
+        let bounds = BoundingBox(min: boundsMin, max: boundsMax)
+        lowLevelMesh.parts.replaceAll([
+            LowLevelMesh.Part(indexCount: indexCount, topology: .triangle, bounds: bounds)
+        ])
+        
+        self.meshGeometry = meshGeometry
+        self.meshGeometry.bounds = bounds
+        invalidatePhysicsMesh()
+    }
+
+    // MARK: - Physics Mesh Cooking
+
+    /// Schedules an async physics mesh cook. If a cook is already in progress,
+    /// it will automatically re-cook when done if the geometry has changed.
+    private func invalidatePhysicsMesh() {
+        guard physicsCookingTask == nil else { return }
+        physicsCookingTask = Task {
+            defer { physicsCookingTask = nil }
+            // Loop until the cooked physics mesh matches the current geometry.
+            // Each iteration cooks against whatever the MeshResource currently reflects.
+            while lastCookedGeometry != meshGeometry {
+                let geometryAtStart = meshGeometry
+                do {
+                    let meshResource = try await MeshResource(from: lowLevelMesh)
+                    let shape = try await ShapeResource.generateStaticMesh(from: meshResource)
+                    collisionShape = shape
+                    lastCookedGeometry = geometryAtStart
+                } catch {
+                    NSLog("SDL_RealityKitHelper: Failed to generate physics mesh: %@", error.localizedDescription)
+                    break
+                }
+            }
+        }
+    }
+
+    // MARK: - Texture Updates (LowLevelTexture Pipeline)
+
+    /// Creates or recreates the LowLevelTexture for the given dimensions
+    private func ensureLowLevelTexture(width: Int, height: Int, pixelFormat: MTLPixelFormat) {
+        // Check if we need to recreate (size or format changed)
+        if let lowLevelTexture,
+           lowLevelTexture.descriptor.width == width,
+           lowLevelTexture.descriptor.height == height,
+           lowLevelTexture.descriptor.pixelFormat == pixelFormat
+        {
+            return
+        }
+
+        //NSLog("SDL_RealityKitHelper: Creating LowLevelTexture %dx%d", width, height)
+
+        do {
+            // Create LowLevelTexture descriptor using Metal pixel format directly
+            var descriptor = LowLevelTexture.Descriptor()
+            descriptor.textureType = .type2D
+            descriptor.pixelFormat = pixelFormat
+            descriptor.width = width
+            descriptor.height = height
+            descriptor.depth = 1
+            let size = max(width, height)
+            if (size > 32) {
+                descriptor.mipmapLevelCount = Int(floor(log2(Float(size)))) - 5
+            } else {
+                descriptor.mipmapLevelCount = 0
+            }
+            descriptor.textureUsage = [.shaderRead, .renderTarget]
+
+            // Create the LowLevelTexture
+            lowLevelTexture = try LowLevelTexture(descriptor: descriptor)
+
+            // Create TextureResource from LowLevelTexture (this is reusable)
+            textureResource = try TextureResource(from: lowLevelTexture!)
+
+            //NSLog("SDL_RealityKitHelper: LowLevelTexture created successfully")
+        } catch {
+            NSLog("SDL_RealityKitHelper: ERROR - Failed to create LowLevelTexture: %@", error.localizedDescription)
+            lowLevelTexture = nil
+            textureResource = nil
+        }
+    }
+
+    @objc public func getDisplayTexture(_ commandBuffer: MTLCommandBuffer, width: Int, height: Int, pixelFormat: MTLPixelFormat) -> MTLTexture? {
+        // Ensure LowLevelTexture exists with correct dimensions
+        ensureLowLevelTexture(
+            width: width,
+            height: height,
+            pixelFormat: pixelFormat
+        )
+
+        guard let llt = lowLevelTexture else {
+            NSLog("SDL_RealityKitHelper: ERROR - No LowLevelTexture available")
+            return nil
+        }
+
+        // Get the writable texture from LowLevelTexture
+        return llt.replace(using: commandBuffer)
+    }
+}
+
+extension SDL_RealityKitHelper.CurvedMeshTopology {
+    @MainActor
+    func generateMesh() throws -> LowLevelMesh {
+        //NSLog("SDL_RealityKitHelper: Creating LowLevelMesh (%dx%d grid, %d vertices, %d indices)",
+        //      segmentsX, segmentsY, vertexCount, indexCount)
+
+        // Create LowLevelMesh with our custom vertex format
+        let desc = CurvedPlaneVertex.descriptor(vertexCount: vertexCount, indexCount: indexCount)
+        let mesh = try LowLevelMesh(descriptor: desc)
+
+        // Write index buffer once — topology never changes for a fixed grid
+        mesh.withUnsafeMutableIndices { rawIndices in
+            let indices = rawIndices.bindMemory(to: UInt32.self)
+            var idx = 0
+            for y in 0..<segmentsY {
+                for x in 0..<segmentsX {
+                    let i0 = UInt32(y * (segmentsX + 1) + x)
+                    let i1 = i0 + 1
+                    let i2 = i0 + UInt32(segmentsX + 1)
+                    let i3 = i2 + 1
+
+                    // Two triangles per quad (counter-clockwise winding)
+                    indices[idx]     = i0
+                    indices[idx + 1] = i1
+                    indices[idx + 2] = i2
+                    indices[idx + 3] = i1
+                    indices[idx + 4] = i3
+                    indices[idx + 5] = i2
+                    idx += 6
+                }
+            }
+        }
+        
+        return mesh
+    }
+}

+ 50 - 0
src/video/uikit/SDL_UIKitBridge-objc.h

@@ -0,0 +1,50 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+#ifndef SDL_uikitvisionosscene_h_
+#define SDL_uikitvisionosscene_h_
+
+#import <UIKit/UIKit.h>
+#import <Metal/Metal.h>
+
+/**
+ * Return true if the curved content pointer mode is enabled
+ */
+bool SDL_VisionOS_PointerModeEnabled();
+
+/**
+ * Check if any window is using curved content mode (UIHostingController-based).
+ */
+bool SDL_UIKit_HasCurvedWindow();
+
+/**
+ * Check if a window is using curved content mode (UIHostingController-based).
+ *
+ * @param window The SDL window to check.
+ * @return true if the window is in curved mode, false otherwise.
+ */
+bool SDL_UIKit_IsCurvedWindow(SDL_Window *window);
+
+/**
+ * Get the curved content display texture.
+ */
+id<MTLTexture> SDL_UIKit_GetCurvedDisplayTexture(SDL_Window *window, id<MTLCommandBuffer> commandBuffer, int width, int height, MTLPixelFormat pixelFormat);
+
+#endif /* SDL_uikitvisionosscene_h_ */

+ 39 - 0
src/video/uikit/SDL_UIKitBridge-swift.h

@@ -0,0 +1,39 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+#import "SDL_uikitviewcontroller.h"
+
+// Called from Swift scene delegates when window size changes
+void SDL_VisionOS_SendSizeChanged(long width, long height);
+
+// Called from Swift scene delegates to get the initial curvature
+float SDL_VisionOS_GetCurvature();
+
+// Called from Swift scene delegates when window curvature changes
+void SDL_VisionOS_SendCurvatureChanged(float curvature);
+
+// Called from Swift scene delegates when pointer mode changes
+void SDL_VisionOS_SendPointerMode(bool enabled);
+
+// Called from Swift scene delegates when visionOS delivers a touch event
+void SDL_VisionOS_SendTouch(NSTimeInterval timestamp, SDL_FingerID fingerID, Uint32 eventType, float x, float y);
+
+// Called from Swift to register the RealityKit hosting object with the SDL window
+void SDL_VisionOS_SetWindowRealityKitHosting(id hosting);

+ 187 - 0
src/video/uikit/SDL_UIKitBridge.m

@@ -0,0 +1,187 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+#include "SDL_internal.h"
+
+#ifdef SDL_PLATFORM_VISIONOS
+
+#include "SDL_UIKitBridge-objc.h"
+#include "SDL_UIKitBridge-swift.h"
+#include "SDL_uikitevents.h"
+#include "SDL_uikitwindow.h"
+#include "SDL_uikitmetalview.h"
+#include "../../events/SDL_events_c.h"
+
+
+// Called from Swift scene delegates when window size changes
+void SDL_VisionOS_SendSizeChanged(long width, long height)
+{
+    SDL_Window *window = SDL_GetToplevelForKeyboardFocus();
+    if (window) {
+        SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)window->internal;
+        CGRect bounds = CGRectMake(0, 0, width, height);
+
+        // Update the UIWindow
+        data.uiwindow.frame = bounds;
+
+        // Update the view
+        UIView *view = data.viewcontroller.view;
+        view.bounds = bounds;
+
+        // Update the metal layer
+        if ([view isKindOfClass:[SDL_uikitmetalview class]]) {
+            SDL_uikitmetalview *metalview = (SDL_uikitmetalview *)view;
+
+            [metalview updateDrawableSize];
+        }
+
+        // Send the resize event
+        SDL_SendWindowEvent(window, SDL_EVENT_WINDOW_RESIZED, (int)width, (int)height);
+    }
+}
+
+// Called from Swift scene delegates to get the initial curvature
+float SDL_VisionOS_GetCurvature()
+{
+    SDL_Window *window = SDL_GetToplevelForKeyboardFocus();
+    if (window) {
+        SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)window->internal;
+        return data.curvature;
+    }
+    return 0.0f;
+}
+
+// Called from Swift scene delegates when window curvature changes
+void SDL_VisionOS_SendCurvatureChanged(float curvature)
+{
+    SDL_Window *window = SDL_GetToplevelForKeyboardFocus();
+    if (window) {
+        SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)window->internal;
+        if (curvature != data.curvature) {
+            data.curvature = curvature;
+            SDL_SetFloatProperty(SDL_GetWindowProperties(window), SDL_PROP_WINDOW_CURVATURE_FLOAT, curvature);
+            SDL_SendWindowEvent(window, SDL_EVENT_WINDOW_CURVATURE_CHANGED, (int)curvature, 0);
+        }
+    }
+}
+
+static bool SDL_pointer_mode;
+
+void SDL_VisionOS_SendPointerMode(bool enabled)
+{
+    SDL_pointer_mode = enabled;
+}
+
+bool SDL_VisionOS_PointerModeEnabled()
+{
+    return SDL_pointer_mode;
+}
+
+// Called from Swift scene delegates when visionOS delivers a touch event
+void SDL_VisionOS_SendTouch(NSTimeInterval timestamp, SDL_FingerID fingerID, Uint32 eventType, float x, float y)
+{
+    const SDL_TouchID directTouchId = 1;
+    SDL_Window *window = SDL_GetToplevelForKeyboardFocus();
+    if (!window) {
+        return;
+    }
+
+    float pressure;
+    if (eventType == SDL_EVENT_FINGER_DOWN || eventType == SDL_EVENT_FINGER_MOTION) {
+        pressure = 1.0f;
+    } else {
+        pressure = 0.0f;
+    }
+    if (eventType == SDL_EVENT_FINGER_MOTION) {
+        SDL_SendTouchMotion(UIKit_GetEventTimestamp(timestamp), directTouchId, fingerID, window, x, y, pressure);
+    } else {
+        SDL_SendTouch(UIKit_GetEventTimestamp(timestamp), directTouchId, fingerID, window, (SDL_EventType)eventType, x, y, pressure);
+    }
+}
+
+// MARK: - RealityKit Content Hosting
+
+// Called from Swift to register the RealityKit hosting object with the SDL window.
+void SDL_VisionOS_SetWindowRealityKitHosting(id hosting)
+{
+    SDL_Window *window = SDL_GetToplevelForKeyboardFocus();
+    if (!window) {
+        SDL_LogError(SDL_LOG_CATEGORY_VIDEO, "VISIONOS: No focused window for RealityKit hosting");
+        return;
+    }
+
+    SDL_UIKitWindowData *windowData = (__bridge SDL_UIKitWindowData *)window->internal;
+    windowData.curvedContentHosting = hosting;
+
+    // Updating curvedContentHosting updates the view controller so that the "container background" is hidden.
+    // On visionOS, this gets rid of the default glass background effect (not wanted for our content).
+    [windowData.viewcontroller setNeedsUpdateOfPreferredContainerBackgroundStyle];
+
+    //SDL_Log("VISIONOS: RealityKit hosting registered");
+}
+
+bool SDL_UIKit_HasCurvedWindow()
+{
+    SDL_Window *window = SDL_GetToplevelForKeyboardFocus();
+    if (window) {
+        return SDL_UIKit_IsCurvedWindow(window);
+    }
+    return false;
+}
+
+bool SDL_UIKit_IsCurvedWindow(SDL_Window *window)
+{
+    SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)window->internal;
+    return data && data.curvedContentHosting;
+}
+
+id<MTLTexture> SDL_UIKit_GetCurvedDisplayTexture(SDL_Window *window, id<MTLCommandBuffer> commandBuffer, int width, int height, MTLPixelFormat pixelFormat)
+{
+    SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)window->internal;
+    if (!data || !data.curvedContentHosting) {
+        return nil;
+    }
+
+    id hosting = data.curvedContentHosting;
+    SEL getTextureSelector = NSSelectorFromString(@"getDisplayTexture:width:height:pixelFormat:");
+    if (![hosting respondsToSelector:getTextureSelector]) {
+        return nil;
+    }
+
+    NSMethodSignature *signature = [hosting methodSignatureForSelector:getTextureSelector];
+    NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
+    [invocation setSelector:getTextureSelector];
+    [invocation setTarget:hosting];
+
+    long arg_width = width;
+    long arg_height = height;
+    [invocation setArgument:&commandBuffer atIndex:2];
+    [invocation setArgument:&arg_width atIndex:3];
+    [invocation setArgument:&arg_height atIndex:4];
+    [invocation setArgument:&pixelFormat atIndex:5];
+    [invocation invoke];
+
+    __unsafe_unretained id temp = nil;
+    [invocation getReturnValue:&temp];
+    id<MTLTexture> texture = temp;
+    return texture;
+}
+
+#endif /* SDL_PLATFORM_VISIONOS */

+ 24 - 6
src/video/uikit/SDL_uikitevents.m

@@ -29,6 +29,7 @@
 #include "SDL_uikitopengles.h"
 #include "SDL_uikitvideo.h"
 #include "SDL_uikitwindow.h"
+#include "SDL_UIKitBridge-objc.h"
 
 #import <Foundation/Foundation.h>
 #import <GameController/GameController.h>
@@ -308,6 +309,12 @@ static bool SetGCMouseRelativeMode(bool enabled)
 static void OnGCMouseButtonChanged(SDL_MouseID mouseID, Uint8 button, BOOL pressed)
 {
     Uint64 timestamp = SDL_GetTicksNS();
+
+#ifdef SDL_PLATFORM_VISIONOS
+    if (!SDL_VisionOS_PointerModeEnabled() && SDL_UIKit_HasCurvedWindow()) {
+        return;
+    }
+#endif
     SDL_SendMouseButton(timestamp, SDL_GetMouseFocus(), mouseID, button, pressed);
 }
 
@@ -318,19 +325,19 @@ static void OnGCMouseConnected(GCMouse *mouse) API_AVAILABLE(macos(11.0), ios(14
     SDL_AddMouse(mouseID, NULL);
 
     mouse.mouseInput.leftButton.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) {
-      OnGCMouseButtonChanged(mouseID, SDL_BUTTON_LEFT, pressed);
+        OnGCMouseButtonChanged(mouseID, SDL_BUTTON_LEFT, pressed);
     };
     mouse.mouseInput.middleButton.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) {
-      OnGCMouseButtonChanged(mouseID, SDL_BUTTON_MIDDLE, pressed);
+        OnGCMouseButtonChanged(mouseID, SDL_BUTTON_MIDDLE, pressed);
     };
     mouse.mouseInput.rightButton.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) {
-      OnGCMouseButtonChanged(mouseID, SDL_BUTTON_RIGHT, pressed);
+        OnGCMouseButtonChanged(mouseID, SDL_BUTTON_RIGHT, pressed);
     };
 
     int auxiliary_button = SDL_BUTTON_X1;
     for (GCControllerButtonInput *btn in mouse.mouseInput.auxiliaryButtons) {
         btn.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) {
-          OnGCMouseButtonChanged(mouseID, auxiliary_button, pressed);
+            OnGCMouseButtonChanged(mouseID, auxiliary_button, pressed);
         };
         ++auxiliary_button;
     }
@@ -338,21 +345,32 @@ static void OnGCMouseConnected(GCMouse *mouse) API_AVAILABLE(macos(11.0), ios(14
     mouse.mouseInput.mouseMovedHandler = ^(GCMouseInput *mouseInput, float deltaX, float deltaY) {
         Uint64 timestamp = SDL_GetTicksNS();
 
-        if (SDL_GCMouseRelativeMode()) {
+        bool send_motion = SDL_GCMouseRelativeMode();
+#ifdef SDL_PLATFORM_VISIONOS
+        if (!send_motion && SDL_VisionOS_PointerModeEnabled()) {
+            send_motion = true;
+        }
+#endif
+        if (send_motion) {
             SDL_SendMouseMotion(timestamp, SDL_GetMouseFocus(), mouseID, true, deltaX, -deltaY);
         }
     };
 
     mouse.mouseInput.scroll.valueChangedHandler = ^(GCControllerDirectionPad *dpad, float xValue, float yValue) {
         Uint64 timestamp = SDL_GetTicksNS();
-
+        
         /* Raw scroll values come in here, vertical values in the first axis, horizontal values in the second axis.
          * The vertical values are negative moving the mouse wheel up and positive moving it down.
          * The horizontal values are negative moving the mouse wheel left and positive moving it right.
          * The vertical values are inverted compared to SDL, and the horizontal values are as expected.
          */
+#ifdef SDL_PLATFORM_VISIONOS
+        float vertical = -yValue;
+        float horizontal = xValue;
+#else
         float vertical = -xValue;
         float horizontal = yValue;
+#endif
 
         if (mouse_scroll_direction == SDL_MOUSEWHEEL_FLIPPED) {
             // Since these are raw values, we need to flip them ourselves

+ 21 - 20
src/video/uikit/SDL_uikitmetalview.h

@@ -1,24 +1,23 @@
 /*
- Simple DirectMedia Layer
- Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
-
- This software is provided 'as-is', without any express or implied
- warranty.  In no event will the authors be held liable for any damages
- arising from the use of this software.
-
- Permission is granted to anyone to use this software for any purpose,
- including commercial applications, and to alter it and redistribute it
- freely, subject to the following restrictions:
-
- 1. The origin of this software must not be misrepresented; you must not
- claim that you wrote the original software. If you use this software
- in a product, an acknowledgment in the product documentation would be
- appreciated but is not required.
- 2. Altered source versions must be plainly marked as such, and must not be
- misrepresented as being the original software.
- 3. This notice may not be removed or altered from any source distribution.
- */
-
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
 /*
  * @author Mark Callow, www.edgewise-consulting.com.
  *
@@ -43,6 +42,8 @@
 - (instancetype)initWithFrame:(CGRect)frame
                         scale:(CGFloat)scale;
 
+- (void)updateDrawableSize;
+
 @end
 
 SDL_MetalView UIKit_Metal_CreateView(SDL_VideoDevice *_this, SDL_Window *window);

+ 19 - 20
src/video/uikit/SDL_uikitmetalview.m

@@ -1,24 +1,23 @@
 /*
- Simple DirectMedia Layer
- Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
-
- This software is provided 'as-is', without any express or implied
- warranty.  In no event will the authors be held liable for any damages
- arising from the use of this software.
-
- Permission is granted to anyone to use this software for any purpose,
- including commercial applications, and to alter it and redistribute it
- freely, subject to the following restrictions:
-
- 1. The origin of this software must not be misrepresented; you must not
- claim that you wrote the original software. If you use this software
- in a product, an acknowledgment in the product documentation would be
- appreciated but is not required.
- 2. Altered source versions must be plainly marked as such, and must not be
- misrepresented as being the original software.
- 3. This notice may not be removed or altered from any source distribution.
- */
-
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
 /*
  * @author Mark Callow, www.edgewise-consulting.com.
  *

+ 18 - 18
src/video/uikit/SDL_uikitview.m

@@ -1,22 +1,22 @@
 /*
- Simple DirectMedia Layer
- Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
-
- This software is provided 'as-is', without any express or implied
- warranty.  In no event will the authors be held liable for any damages
- arising from the use of this software.
-
- Permission is granted to anyone to use this software for any purpose,
- including commercial applications, and to alter it and redistribute it
- freely, subject to the following restrictions:
-
- 1. The origin of this software must not be misrepresented; you must not
-    claim that you wrote the original software. If you use this software
-    in a product, an acknowledgment in the product documentation would be
-    appreciated but is not required.
- 2. Altered source versions must be plainly marked as such, and must not be
-    misrepresented as being the original software.
- 3. This notice may not be removed or altered from any source distribution.
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
 */
 #include "SDL_internal.h"
 

+ 4 - 0
src/video/uikit/SDL_uikitviewcontroller.h

@@ -59,6 +59,10 @@
 - (void)loadView;
 - (void)viewDidLayoutSubviews;
 
+#ifdef SDL_PLATFORM_VISIONOS
+- (void)initializeVisionOSCurvedUI;
+#endif
+
 #ifndef SDL_PLATFORM_TVOS
 - (NSUInteger)supportedInterfaceOrientations;
 - (BOOL)prefersStatusBarHidden;

+ 26 - 0
src/video/uikit/SDL_uikitviewcontroller.m

@@ -33,6 +33,10 @@
 #include "SDL_uikitwindow.h"
 #include "SDL_uikitopengles.h"
 
+#ifdef SDL_PLATFORM_VISIONOS
+#import "SDL3/SDL3-Swift.h"
+#endif
+
 #ifdef SDL_PLATFORM_TVOS
 static void SDLCALL SDL_AppleTVControllerUIHintChanged(void *userdata, const char *name, const char *oldValue, const char *hint)
 {
@@ -119,6 +123,15 @@ static void SDLCALL SDL_HideHomeIndicatorHintChanged(void *userdata, const char
             }
         }
     }
+
+#ifdef SDL_PLATFORM_VISIONOS
+    if (@available(visionOS 26.0, *)) {
+        SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)self.window->internal;
+        if (data.curvature >= 0.0f) {
+            [self initializeVisionOSCurvedUI];
+        }
+    }
+#endif
     return self;
 }
 
@@ -141,6 +154,19 @@ static void SDLCALL SDL_HideHomeIndicatorHintChanged(void *userdata, const char
 #endif
 }
 
+#ifdef SDL_PLATFORM_VISIONOS
+- (UIContainerBackgroundStyle)preferredContainerBackgroundStyle
+{
+    if (self.window) {
+        SDL_UIKitWindowData *data = (__bridge SDL_UIKitWindowData *)self.window->internal;
+        if (data && data.curvedContentHosting) {
+            return UIContainerBackgroundStyleHidden;
+        }
+    }
+    return UIContainerBackgroundStyleAutomatic;
+}
+#endif
+
 - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection
 {
     SDL_SetSystemTheme(UIKit_GetSystemTheme());

+ 33 - 0
src/video/uikit/SDL_uikitviewcontroller.swift

@@ -0,0 +1,33 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2026 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+
+import SwiftUI
+
+extension SDL_uikitviewcontroller {
+    @available(visionOS 26.0, *)
+    @objc func initializeVisionOSCurvedUI() {
+        Task {
+            let hosting = SDL_CurvedContentHosting()
+            hosting.present(from: self)
+            SDL_VisionOS_SetWindowRealityKitHosting(hosting)
+        }
+    }
+}

+ 6 - 0
src/video/uikit/SDL_uikitwindow.h

@@ -52,6 +52,12 @@ extern NSUInteger UIKit_GetSupportedOrientations(SDL_Window *window);
 // Array of SDL_uikitviews owned by this window.
 @property(nonatomic, copy) NSMutableArray *views;
 
+#ifdef SDL_PLATFORM_VISIONOS
+// Hosting controller for curved content mode (UIHostingController-based)
+@property(nonatomic, strong) id curvedContentHosting;
+@property(nonatomic, assign) CGFloat curvature;
+#endif
+
 @end
 
 #endif // SDL_uikitwindow_h_

+ 15 - 6
src/video/uikit/SDL_uikitwindow.m

@@ -53,7 +53,7 @@
 
 @end
 
-static bool SetupWindowData(SDL_VideoDevice *_this, SDL_Window *window, UIWindow *uiwindow, bool created)
+static bool SetupWindowData(SDL_VideoDevice *_this, SDL_Window *window, UIWindow *uiwindow, SDL_PropertiesID create_props, bool created)
 {
     SDL_VideoDisplay *display = SDL_GetVideoDisplayForWindow(window);
     SDL_UIKitDisplayData *displaydata = (__bridge SDL_UIKitDisplayData *)display->internal;
@@ -106,6 +106,19 @@ static bool SetupWindowData(SDL_VideoDevice *_this, SDL_Window *window, UIWindow
 #endif
     window->w = width;
     window->h = height;
+    
+    SDL_PropertiesID props = SDL_GetWindowProperties(window);
+    SDL_SetPointerProperty(props, SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, (__bridge void *)data.uiwindow);
+    SDL_SetNumberProperty(props, SDL_PROP_WINDOW_UIKIT_METAL_VIEW_TAG_NUMBER, SDL_METALVIEW_TAG);
+
+#ifdef SDL_PLATFORM_VISIONOS
+    float curvature = SDL_GetFloatProperty(create_props, SDL_PROP_WINDOW_CREATE_CURVATURE_FLOAT, -1.0f);
+    if (curvature > 0.0f && curvature <= 1.0f) {
+        curvature = 0.0f;
+    }
+    data.curvature = curvature;
+    SDL_SetFloatProperty(props, SDL_PROP_WINDOW_CURVATURE_FLOAT, curvature);
+#endif
 
     /* The View Controller will handle rotating the view when the device
      * orientation changes. This will trigger resize events, if appropriate. */
@@ -119,10 +132,6 @@ static bool SetupWindowData(SDL_VideoDevice *_this, SDL_Window *window, UIWindow
      * hierarchy. */
     [view setSDLWindow:window];
 
-    SDL_PropertiesID props = SDL_GetWindowProperties(window);
-    SDL_SetPointerProperty(props, SDL_PROP_WINDOW_UIKIT_WINDOW_POINTER, (__bridge void *)data.uiwindow);
-    SDL_SetNumberProperty(props, SDL_PROP_WINDOW_UIKIT_METAL_VIEW_TAG_NUMBER, SDL_METALVIEW_TAG);
-
     return true;
 }
 
@@ -228,7 +237,7 @@ bool UIKit_CreateWindow(SDL_VideoDevice *_this, SDL_Window *window, SDL_Properti
         }
 #endif
 
-        if (!SetupWindowData(_this, window, uiwindow, true)) {
+        if (!SetupWindowData(_this, window, uiwindow, create_props, true)) {
             return false;
         }
     }