From e7e732a7ce3cedbe68d2bd56ab2d47ae3477245f Mon Sep 17 00:00:00 2001 From: Geordie Jay Date: Fri, 12 Dec 2025 16:46:21 +0100 Subject: [PATCH 1/6] Flatten Layers to allow for proper masking and opacity compositing. Add CAGradientLayer --- SDL | 2 +- Sources/AVPlayerLayer+Mac.swift | 14 +- Sources/CAGradientLayer.swift | 172 ++++++++++++++++ Sources/CALayer+ContentsGravity.swift | 4 +- Sources/CALayer+SDL.swift | 95 +++++---- Sources/CALayer.swift | 8 + Sources/CGImage.swift | 6 +- Sources/FontRenderer+singleLineSize.swift | 2 +- Sources/MaskingShaders.swift | 52 ----- ...Errors.swift => RenderTarget+Errors.swift} | 2 +- Sources/RenderTarget.swift | 192 ++++++++++++++++++ Sources/Shader.swift | 17 +- Sources/ShaderProgram+Gradient.swift | 124 +++++++++++ Sources/ShaderProgram+mask.swift | 50 ++++- Sources/ShaderProgram.swift | 22 ++ Sources/UIScreen+render.swift | 117 +---------- Sources/UIScreen.swift | 51 +++-- Sources/UIView+SDL.swift | 6 +- UIKit.xcodeproj/project.pbxproj | 20 +- 19 files changed, 683 insertions(+), 273 deletions(-) create mode 100644 Sources/CAGradientLayer.swift delete mode 100644 Sources/MaskingShaders.swift rename Sources/{UIScreen+Errors.swift => RenderTarget+Errors.swift} (98%) create mode 100644 Sources/RenderTarget.swift create mode 100644 Sources/ShaderProgram+Gradient.swift diff --git a/SDL b/SDL index 617ec8c0..2056081c 160000 --- a/SDL +++ b/SDL @@ -1 +1 @@ -Subproject commit 617ec8c042a882060f133760eda4bf0cecde0bcc +Subproject commit 2056081cd9815112c002fea5836d8fd933d2b757 diff --git a/Sources/AVPlayerLayer+Mac.swift b/Sources/AVPlayerLayer+Mac.swift index 13a08b31..085b063f 100644 --- a/Sources/AVPlayerLayer+Mac.swift +++ b/Sources/AVPlayerLayer+Mac.swift @@ -8,7 +8,7 @@ // import AVFoundation -internal import var SDL_gpu.GPU_FORMAT_RGBA +internal import var SDL_gpu.GPU_FORMAT_BGRA public typealias AVPlayer = AVFoundation.AVPlayer @@ -88,7 +88,6 @@ public final class AVPlayerLayer: CALayer { currentPlayerOutputSize = size } - @_optimize(speed) func updateVideoFrame() { updatePlayerOutput(size: frame.size) guard @@ -109,16 +108,7 @@ public final class AVPlayerLayer: CALayer { if contents?.width != width || contents?.height != height { contentsScale = 1.0 // this doesn't work on init because we set contentsScale in UIView.init afterwards - contents = VideoTexture(width: width, height: height, format: GPU_FORMAT_RGBA) - } - - // Swap R and B values to get RGBA pixels instead of BGRA: - let bufferSize = CVPixelBufferGetDataSize(pixelBuffer) - for i in stride(from: 0, to: bufferSize, by: 16) { - swap(&pixelBytes[i], &pixelBytes[i + 2]) - swap(&pixelBytes[i+4], &pixelBytes[i + 6]) - swap(&pixelBytes[i+8], &pixelBytes[i + 10]) - swap(&pixelBytes[i+12], &pixelBytes[i + 14]) + contents = VideoTexture(width: width, height: height, format: GPU_FORMAT_BGRA) } contents?.replacePixels(with: pixelBytes, bytesPerPixel: 4) diff --git a/Sources/CAGradientLayer.swift b/Sources/CAGradientLayer.swift new file mode 100644 index 00000000..16308a88 --- /dev/null +++ b/Sources/CAGradientLayer.swift @@ -0,0 +1,172 @@ +internal import SDL +private import SDL_gpu + +open class CAGradientLayer: CALayer { + public var colors: [CGColor] = [] { + didSet { + _colors = colors.map { + SIMD4( + x: CGFloat($0.redValue) / 255, + y: CGFloat($0.greenValue) / 255, + z: CGFloat($0.blueValue) / 255, + w: CGFloat($0.alphaValue) / 255 + ) + } + setNeedsDisplay() + } + } + + private var _colors: [SIMD4] = [] + + public var locations: [Float] = [] { + didSet { setNeedsDisplay() } + } + + public var startPoint = CGPoint(x: 0.5, y: 0.0) { + didSet { setNeedsDisplay() } + } + + public var endPoint = CGPoint(x: 0.5, y: 1.0) { + didSet { setNeedsDisplay() } + } + + @_optimize(speed) + override open func display() { + super.display() + + if bounds.width.isZero || bounds.height.isZero { + return + } + + if locations.count < colors.count { + if colors.count == 1 { + backgroundColor = colors[0] + colors = [] + locations = [] + } else { + locations = colors.indices.map { Float($0) / Float(colors.count - 1) } + } + + } + + if colors.isEmpty { + contents = nil + return + } + + let width: Int32 + let height: Int32 + + if startPoint.x == endPoint.x { + width = 1 + height = Int32(bounds.height * self.contentsScale) + } else if startPoint.y == endPoint.y { + width = Int32(bounds.width * self.contentsScale) + height = 1 + } else { + // pessimistic case where we need to fill the entire thing + width = Int32(bounds.width * self.contentsScale) + height = Int32(bounds.height * self.contentsScale) + } + + guard let surface = SDL_CreateRGBSurfaceWithFormat( + 0, // surface flags are always 0 in SDL2 + width, + height, + 32, // bit depth + UInt32(SDL_PIXELFORMAT_RGBA32) + ) else { + return + } + + SDL_LockSurface(surface) + + let startPoint = SIMD2(x: self.startPoint.x, y: self.startPoint.y) + let endPoint = SIMD2(x: self.endPoint.x, y: self.endPoint.y) + + for y in 0 ..< Int(height) { + let pixelPosY = y * Int(surface.pointee.pitch) + + for x in 0 ..< Int(width) { + let pixel = surface.pointee.pixels.assumingMemoryBound(to: UInt8.self) + .advanced(by: pixelPosY) + .advanced(by: x * Int(surface.pointee.format.pointee.BytesPerPixel)) + + let p = SIMD2(x: CGFloat(x) / bounds.width, y: CGFloat(y) / bounds.height) + + // Direction of gradient line + let dir = endPoint - startPoint + let len2 = dot(dir, dir) + + var t: CGFloat = 0.0 + if len2 > 0.00001 { + // Project (p - startPoint) onto the gradient direction + t = dot(p - startPoint, dir) / len2 + } + + t = max(0.0, min(t, 1.0)) + let color = sampleGradient(t) + + let normalized = color * 255 + pixel[0] = UInt8(clamping: Int(normalized.x)) + pixel[1] = UInt8(clamping: Int(normalized.y)) + pixel[2] = UInt8(clamping: Int(normalized.z)) + pixel[3] = UInt8(clamping: Int(normalized.w)) + } + } + + SDL_UnlockSurface(surface) + + if + let contents, + contents.width != Int(bounds.width) || + contents.height != Int(bounds.height) + { + contents.replacePixels( + with: surface.pointee.pixels.assumingMemoryBound(to: UInt8.self), + bytesPerPixel: Int(surface.pointee.format.pointee.BytesPerPixel) + ) + } else { + contents = CGImage(surface: surface) + } + } + + @_optimize(speed) + func sampleGradient(_ position: CGFloat) -> SIMD4 { + precondition(colors.count >= 2) + precondition(locations.count >= 2) + + let t = Float(max(0.0, min(position, 1.0))) + + if t <= locations.first! { + return _colors.first! + } else if t >= locations.last! { + return _colors.last! + } + + // Find stops [i, i+1] containing t + for i in 0 ..< (colors.count - 1) { + let loc0 = locations[i] + let loc1 = locations[i + 1] + + if t >= loc0 && t <= loc1 { + let localT = (t - loc0) / (loc1 - loc0) + return mix(_colors[i], _colors[i + 1], t: CGFloat(localT)) + } + } + + return _colors.last! + } +} + + +// GLSL-style mix for SIMD4 +@inline(__always) +func mix(_ a: SIMD4, _ b: SIMD4, t: CGFloat) -> SIMD4 { + return a + (b - a) * t +} + +@inline(__always) +func dot(_ a: SIMD2, _ b: SIMD2) -> CGFloat { + return a.x * b.x + a.y * b.y +} diff --git a/Sources/CALayer+ContentsGravity.swift b/Sources/CALayer+ContentsGravity.swift index 703d2a3e..47150c68 100644 --- a/Sources/CALayer+ContentsGravity.swift +++ b/Sources/CALayer+ContentsGravity.swift @@ -22,8 +22,8 @@ struct ContentsGravityTransformation { /// Warning, this assumes `layer` has `contents` and will crash otherwise! init(for layer: CALayer) { let scaledContents = CGSize( - width: CGFloat(layer.contents!.width) / layer.contentsScale, - height: CGFloat(layer.contents!.height) / layer.contentsScale + width: (layer.contents.map { CGFloat($0.width) } ?? layer.bounds.width) / layer.contentsScale, + height: (layer.contents.map { CGFloat($0.height) } ?? layer.bounds.height) / layer.contentsScale ) let bounds = layer.bounds diff --git a/Sources/CALayer+SDL.swift b/Sources/CALayer+SDL.swift index 919cdf16..6ff0c781 100644 --- a/Sources/CALayer+SDL.swift +++ b/Sources/CALayer+SDL.swift @@ -1,17 +1,9 @@ -// -// CALayer+SDL.swift -// UIKit -// -// Created by Chris on 27.06.17. -// Copyright © 2017 flowkey. All rights reserved. -// - internal import SDL_gpu extension CALayer { @MainActor - final func sdlRender(parentAbsoluteOpacity: Float = 1) { - guard let renderer = UIScreen.main else { return } + final func sdlRender(parentAbsoluteOpacity: Float = 1, renderTarget: RenderTarget) { + let renderTarget = self.renderTarget ?? renderTarget let opacity = self.opacity * parentAbsoluteOpacity if isHidden || opacity < 0.01 { return } @@ -20,8 +12,13 @@ extension CALayer { // – which may in turn be affected by its parents, and so on) to `position`, and then render rectangles // which may (and often do) start at a negative `origin` based on our (bounds) `size` and `anchorPoint`: let parentOriginTransform = CATransform3D(unsafePointer: GPU_GetCurrentMatrix()) + let translationToPosition = CATransform3DMakeTranslation(position.x, position.y, zPosition) - let transformAtPositionInParentCoordinates = parentOriginTransform * translationToPosition + let transformAtPositionInParentCoordinates = ( + self.renderTarget == nil ? + parentOriginTransform : + CATransform3DMakeTranslation(0, 0, 0) // start from (0,0) TODO: should this be based on layer.bounds? + ) * translationToPosition let modelViewTransform = transformAtPositionInParentCoordinates * self.transform @@ -37,7 +34,7 @@ extension CALayer { // Big performance optimization. Don't render anything that's entirely offscreen: let absoluteFrame = renderedBoundsRelativeToAnchorPoint.applying(modelViewTransform) - guard absoluteFrame.intersects(renderer.bounds) else { + guard absoluteFrame.intersects(renderTarget.bounds) else { return } @@ -49,33 +46,33 @@ extension CALayer { // MARK: Masking / clipping rect - let previousClippingRect = renderer.clippingRect + let previousClippingRect = renderTarget.clippingRect if masksToBounds { // If a previous clippingRect exists restrict it further, otherwise just set it: - renderer.clippingRect = previousClippingRect?.intersection(absoluteFrame) ?? absoluteFrame + renderTarget.clippingRect = previousClippingRect?.intersection(absoluteFrame) ?? absoluteFrame } // If a mask exists, take it into account when rendering by combining absoluteFrame with the mask's frame - if let mask = mask { - // XXX: we're probably not doing exactly what iOS does if there is a transform on here somewhere + if let mask { let maskFrame = (mask.presentation() ?? mask).frame let maskAbsoluteFrame = maskFrame.offsetBy(absoluteFrame.origin) // Don't intersect with previousClippingRect: in a case where both `masksToBounds` and `mask` are // present, using previousClippingRect would not constrain the area as much as it might otherwise - renderer.clippingRect = - renderer.clippingRect?.intersection(maskAbsoluteFrame) ?? maskAbsoluteFrame + renderTarget.clippingRect = + renderTarget.clippingRect?.intersection(maskAbsoluteFrame) ?? maskAbsoluteFrame - if let maskContents = mask.contents { - ShaderProgram.mask.activate() // must activate before setting parameters (below)! - ShaderProgram.mask.set(maskImage: maskContents, frame: mask.bounds) + if mask.needsDisplay() { + mask.display() + mask._needsDisplay = false } + // the actual mask contents get applied separately during compositing } - if let backgroundColor = backgroundColor { + if let backgroundColor { let backgroundColorOpacity = opacity * backgroundColor.alphaValue.toNormalisedFloat() - renderer.fill( + renderTarget.fill( renderedBoundsRelativeToAnchorPoint, with: backgroundColor.withAlphaComponent(CGFloat(backgroundColorOpacity)), cornerRadius: cornerRadius @@ -83,7 +80,7 @@ extension CALayer { } if borderWidth > 0 { - renderer.outline( + renderTarget.outline( renderedBoundsRelativeToAnchorPoint, lineColor: borderColor.withAlphaComponent(CGFloat(opacity)), lineThickness: borderWidth, @@ -95,7 +92,7 @@ extension CALayer { let absoluteShadowOpacity = shadowOpacity * opacity * 0.5 // for "shadow" effect ;) if absoluteShadowOpacity > 0.01 { - renderer.fill( + renderTarget.fill( shadowPath.offsetBy(deltaFromAnchorPointToOrigin), with: shadowColor.withAlphaComponent(CGFloat(absoluteShadowOpacity)), cornerRadius: 2 @@ -103,14 +100,20 @@ extension CALayer { } } - if let contents = contents { + if needsDisplay() { + display() + _needsDisplay = false + } + + if let contents { do { - try renderer.blit( + try renderTarget.blit( contents, anchorPoint: anchorPoint, contentsScale: contentsScale, contentsGravity: ContentsGravityTransformation(for: self), - opacity: opacity + // if we have a render target, opacity is applied on blit below + opacity: self.renderTarget == nil ? opacity : 1 ) } catch { // Try to recreate contents from source data if it exists @@ -122,11 +125,7 @@ extension CALayer { } } - if mask != nil { - ShaderProgram.deactivateAll() - } - - if let sublayers = sublayers { + if let sublayers { // `position` is always relative from the parent's origin, but the global GPU matrix is currently // focused on `self.position` rather than the `origin` we calculated to render rectangles. // We need to be at `origin` here though so we can translate to the next `position` in each sublayer. @@ -143,14 +142,38 @@ extension CALayer { transformAtSelfOrigin.setAsSDLgpuMatrix() for sublayer in sublayers { - (sublayer.presentation() ?? sublayer).sdlRender(parentAbsoluteOpacity: opacity) + let layer = sublayer.presentation() ?? sublayer + layer.sdlRender(parentAbsoluteOpacity: opacity, renderTarget: renderTarget) + + // A layer will render its subtree to a separate target if + // it e.g. contains a mask or has a non-zero opacity. + // This allows us to render the entire subtree and then apply + // the opacity / mask to everything and not just the base layer. + if let subtreeRenderTarget = layer.renderTarget { + if let mask = layer.mask, let maskContents = mask.contents { + ShaderProgram.maskCompositor.activate() // must activate before setting parameters + ShaderProgram.maskCompositor.set(maskImage: maskContents) + } + do { + try renderTarget.blit( + renderTarget: subtreeRenderTarget, + opacity: layer.opacity + ) + subtreeRenderTarget.clear() + } catch { + print(error) + } + + if layer.mask?.contents != nil { + ShaderProgram.deactivateAll() + } + } } } // We're done rendering this part of the tree // To render further siblings we need to return to our parent's transform (at its `origin`). parentOriginTransform.setAsSDLgpuMatrix() - - renderer.clippingRect = previousClippingRect + renderTarget.clippingRect = previousClippingRect } } diff --git a/Sources/CALayer.swift b/Sources/CALayer.swift index 3fbf579f..ecfff758 100644 --- a/Sources/CALayer.swift +++ b/Sources/CALayer.swift @@ -8,6 +8,12 @@ open class CALayer { didSet { CALayer.layerTreeIsDirty = true } } + // The default RenderTarget is UIScreen.main. + // Some subtrees (e.g. with masking or opacity) need to be rendered separately + // and then composited onto the screen. The render target is created and managed + // by our implementation when needed. + internal var renderTarget: RenderTarget? + /// Defaults to 1.0 but if the layer is associated with a view, /// the view sets this value to match the screen. open var contentsScale: CGFloat = 1.0 @@ -129,6 +135,7 @@ open class CALayer { if !isPresentationForAnotherLayer && bounds.size != newBounds.size { // It seems weird to access the superview here but it matches the iOS behaviour (self.superlayer?.delegate as? UIView)?.setNeedsLayout() + self.setNeedsDisplay() } } @@ -174,6 +181,7 @@ open class CALayer { public var mask: CALayer? { didSet { mask?.superlayer = self + setRenderTargetIfNeeded() } } diff --git a/Sources/CGImage.swift b/Sources/CGImage.swift index ce2a4e4f..189d4cb8 100644 --- a/Sources/CGImage.swift +++ b/Sources/CGImage.swift @@ -24,7 +24,7 @@ public class CGImage { // We check for GPU errors on render, so clear any error that may have caused GPU_Image to be nil. // It's possible there are unrelated errors on the stack at this point, but we immediately catch and // handle any errors that interest us *when they occur*, so it's fine to clear unrelated ones here. - Task { @MainActor in UIScreen.main?.clearErrors() } + Task { @MainActor in UIScreen.main?.renderTarget.clearErrors() } return nil } @@ -39,6 +39,10 @@ public class CGImage { height = Int(rawPointer.pointee.h) } + public func savePNG(filename: String) { + GPU_SaveImage(rawPointer, filename, GPU_FILE_PNG) + } + internal convenience init?(_ sourceData: Data) { var data = sourceData diff --git a/Sources/FontRenderer+singleLineSize.swift b/Sources/FontRenderer+singleLineSize.swift index 6e1bd5a3..d14ff6ae 100644 --- a/Sources/FontRenderer+singleLineSize.swift +++ b/Sources/FontRenderer+singleLineSize.swift @@ -114,7 +114,7 @@ extension FontRenderer { let spaceCharacterCode = 32 let newLineCharacterCode = 10 if characterCode != spaceCharacterCode, characterCode != newLineCharacterCode, glyph.maxx - glyph.minx <= 0 { - assertionFailure("Glyph \(characterCode) ('\(Character(UnicodeScalar(characterCode)!))') has no width") +// assertionFailure("Glyph \(characterCode) ('\(Character(UnicodeScalar(characterCode)!))') has no width") return nil } diff --git a/Sources/MaskingShaders.swift b/Sources/MaskingShaders.swift deleted file mode 100644 index cb4e8a20..00000000 --- a/Sources/MaskingShaders.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// MaskingShaders.swift -// UIKit -// -// Created by Geordie Jay on 25.10.17. -// Copyright © 2017 flowkey. All rights reserved. -// - -extension VertexShader { - static let common = try! VertexShader(source: - """ - \(`in`) vec3 gpu_Vertex; - \(`in`) vec2 gpu_TexCoord; - \(`in`) vec4 gpu_Color; - uniform mat4 gpu_ModelViewProjectionMatrix; - - \(`out`) vec4 originalColour; - \(`out`) vec2 absolutePixelPos; - - void main(void) - { - originalColour = gpu_Color; - absolutePixelPos = vec2(gpu_Vertex.xy); - gl_Position = gpu_ModelViewProjectionMatrix * vec4(gpu_Vertex, 1.0); - } - """ - ) -} - -extension FragmentShader { - static let maskColourWithImage = try! FragmentShader(source: """ - \(`in`) vec4 originalColour; - \(`in`) vec2 absolutePixelPos; - - \(fragColorDefinition) - - uniform vec4 maskFrame; - uniform sampler2D maskTexture; - - void main(void) - { - vec2 maskCoordinate = vec2( - ((absolutePixelPos.x - maskFrame.x) / maskFrame.w), - ((absolutePixelPos.y - maskFrame.y) / maskFrame.z) // z == height - ); - - vec4 maskColour = \(texture)(maskTexture, maskCoordinate); - \(fragColor) = vec4(originalColour.rgb, originalColour.a * maskColour.a); - } - """ - ) -} diff --git a/Sources/UIScreen+Errors.swift b/Sources/RenderTarget+Errors.swift similarity index 98% rename from Sources/UIScreen+Errors.swift rename to Sources/RenderTarget+Errors.swift index d22fdad7..0a2ec1df 100644 --- a/Sources/UIScreen+Errors.swift +++ b/Sources/RenderTarget+Errors.swift @@ -8,7 +8,7 @@ internal import SDL_gpu -extension UIScreen { +extension RenderTarget { func clearErrors() { let lastError = GPU_PopErrorCode() if lastError.error != GPU_ERROR_NONE { diff --git a/Sources/RenderTarget.swift b/Sources/RenderTarget.swift new file mode 100644 index 00000000..0827efee --- /dev/null +++ b/Sources/RenderTarget.swift @@ -0,0 +1,192 @@ +internal import SDL_gpu + +internal class RenderTarget { + var rawPointer: UnsafeMutablePointer + + let bounds: CGRect + var scale: CGFloat = 1.0 + var clippingRect: CGRect? { + didSet { + guard let clippingRect else { + return GPU_UnsetClip(rawPointer) + } + + if rawPointer.pointee.image == nil { + GPU_SetClipRect(rawPointer, clippingRect.gpuRect(scale: scale)) + } else { + // for some reason using an image as a target works differently than the window.. + GPU_SetClipRect( + rawPointer, + GPU_Rect( + x: Float(clippingRect.minX), + y: Float(clippingRect.minY), + w: Float(clippingRect.width * scale), + h: Float(clippingRect.height * scale) + ) + ) + } + } + } + + init(_ gpuTarget: UnsafeMutablePointer) { + rawPointer = gpuTarget + bounds = CGRect(x: 0, y: 0, width: CGFloat(rawPointer.pointee.w), height: CGFloat(rawPointer.pointee.h)) + } + + init(image: UnsafeMutablePointer) { + rawPointer = GPU_LoadTarget(image) + bounds = CGRect(x: 0, y: 0, width: CGFloat(rawPointer.pointee.w), height: CGFloat(rawPointer.pointee.h)) + } + + deinit { + // XXX: Does this play nice when RenderTarget == UIScreen? + GPU_FreeTarget(rawPointer) + } +} + +extension RenderTarget { + func blit( + _ image: CGImage, + anchorPoint: CGPoint, + contentsScale: CGFloat, + contentsGravity: ContentsGravityTransformation, + opacity: Float + ) throws { + GPU_SetAnchor(image.rawPointer, Float(anchorPoint.x), Float(anchorPoint.y)) + GPU_SetRGBA(image.rawPointer, 255, 255, 255, opacity.normalisedToUInt8()) + + GPU_BlitTransform( + image.rawPointer, + nil, + self.rawPointer, + Float(contentsGravity.offset.x), + Float(contentsGravity.offset.y), + 0, // rotation in degrees + Float(contentsGravity.scale.width / contentsScale), + Float(contentsGravity.scale.height / contentsScale) + ) + + try throwOnErrors(ofType: [GPU_ERROR_USER_ERROR]) + } + + func blit(renderTarget: RenderTarget, opacity: Float) throws { + GPU_SetAnchor(renderTarget.rawPointer.pointee.image, 0, 0) + GPU_SetRGBA(renderTarget.rawPointer.pointee.image, 255, 255, 255, opacity.normalisedToUInt8()) + + GPU_BlitTransform( + renderTarget.rawPointer.pointee.image, + nil, + self.rawPointer, + 0, // offset + 0, // offset + 0, // rotation in degrees + Float(1 / scale), + Float(1 / scale) + ) + + try throwOnErrors(ofType: [GPU_ERROR_USER_ERROR]) + } + + func setShapeBlending(_ newValue: Bool) { + GPU_SetShapeBlending(newValue) + } + + func setShapeBlendMode(_ newValue: GPU_BlendPresetEnum) { + GPU_SetShapeBlendMode(newValue) + } + + func clear() { + GPU_Clear(rawPointer) + } + + func fill(_ rect: CGRect, with color: UIColor, cornerRadius: CGFloat) { + if cornerRadius >= 1 { + GPU_RectangleRoundFilled(rawPointer, rect.gpuRect(scale: scale), cornerRadius: Float(cornerRadius), color: color.sdlColor) + } else { + GPU_RectangleFilled(rawPointer, rect.gpuRect(scale: scale), color: color.sdlColor) + } + } + + func outline(_ rect: CGRect, lineColor: UIColor, lineThickness: CGFloat) { + // we want to render the outline 'inside' the rect rather + // than exceeding the bounds when lineThickness is bigger than 1 + let offset = lineThickness / 2 + let scaledGpuRect = CGRect( + x: rect.origin.x + offset, + y: rect.origin.y + offset, + width: rect.size.width - offset, + height: rect.size.height - offset + ).gpuRect(scale: scale) + + GPU_SetLineThickness(Float(lineThickness)) + GPU_Rectangle(rawPointer, scaledGpuRect, color: lineColor.sdlColor) + } + + func outline(_ rect: CGRect, lineColor: UIColor, lineThickness: CGFloat, cornerRadius: CGFloat) { + if cornerRadius > 1 { + // we want to render the outline 'inside' the rect rather + // than exceeding the bounds when lineThickness is bigger than 1 + let offset = lineThickness / 2 + let scaledGpuRect = CGRect( + x: rect.origin.x + offset, + y: rect.origin.y + offset, + width: rect.size.width - offset, + height: rect.size.height - offset + ).gpuRect(scale: scale) + + GPU_SetLineThickness(Float(lineThickness)) + GPU_RectangleRound(rawPointer, scaledGpuRect, cornerRadius: Float(cornerRadius), color: lineColor.sdlColor) + } else { + outline(rect, lineColor: lineColor, lineThickness: lineThickness) + } + } + + func flip() throws { + GPU_Flip(rawPointer) + try throwOnErrors(ofType: [GPU_ERROR_USER_ERROR, GPU_ERROR_BACKEND_ERROR]) + } + + func setVirtualResolution(w: UInt16, h: UInt16) { + GPU_SetVirtualResolution(rawPointer, w, h) + } +} + +private extension CGRect { + func gpuRect(scale: CGFloat) -> GPU_Rect { + return GPU_Rect( + x: Float((self.origin.x * scale).rounded() / scale), + y: Float((self.origin.y * scale).rounded() / scale), + w: Float((self.size.width * scale).rounded() / scale), + h: Float((self.size.height * scale).rounded() / scale) + ) + } +} + + +extension CALayer { + func setRenderTargetIfNeeded() { + if mask == nil, (opacity == 1 || sublayers == nil) { + renderTarget = nil + return + } + + let bounds = if masksToBounds || mask?.bounds == nil { + mask?.bounds ?? bounds + } else { + UIScreen.main?.bounds ?? bounds + } + + guard let image = GPU_CreateImage( + UInt16(bounds.width * contentsScale), + UInt16(bounds.height * contentsScale), + GPU_FORMAT_RGBA + ) else { + assertionFailure("Couldn't create render target") + return + } + + renderTarget = RenderTarget(image: image) + renderTarget?.scale = contentsScale + renderTarget?.setVirtualResolution(w: UInt16(bounds.width), h: UInt16(bounds.height)) + } +} diff --git a/Sources/Shader.swift b/Sources/Shader.swift index 116e66c4..c685ea18 100644 --- a/Sources/Shader.swift +++ b/Sources/Shader.swift @@ -8,7 +8,7 @@ internal import SDL_gpu -class VertexShader: Shader { +final class VertexShader: Shader { // Some keywords have changed since the earlier shader language versions available on Android: #if os(Android) // GLES/GLSL: static let `in` = "attribute" @@ -23,7 +23,7 @@ class VertexShader: Shader { } } -class FragmentShader: Shader { +final class FragmentShader: Shader { // Some keywords have changed since the earlier shader language versions available on Android: #if os(Android) // GLES/GLSL: static let `in` = "varying" @@ -52,19 +52,6 @@ class Shader { case compilationFailed(reason: String) } - // To be overriden in subclasses - // e.g. We need to do different overrides depending on whether we're in a vertex / fragment shader -// static func transformedSource(_ source: String) -> String { -// if self is VertexShader { -// return source -// .replacingOccurrences(of: "(^|\n)in", with: "\nattribute", options: .regularExpression) -// .replacingOccurrences(of: "(^|\n)out", with: "\nvarying", options: .regularExpression) -// .replacingOccurrences(of: "(^|\n)texture", with: "\ntexture2D", options: .regularExpression) -// } else { -// return source -// } -// } - // Some hardware (from ATI/AMD) does not let you put non-#version preprocessing at the top of the file. Adds the // appropriate header before compiling, allowing us to use the same shader across different OPENGL(ES) versions.. fileprivate init(_ source: String, type: GPU_ShaderEnum) throws { diff --git a/Sources/ShaderProgram+Gradient.swift b/Sources/ShaderProgram+Gradient.swift new file mode 100644 index 00000000..1d1950ce --- /dev/null +++ b/Sources/ShaderProgram+Gradient.swift @@ -0,0 +1,124 @@ +// +// Mask.swift +// UIKit +// +// Created by Geordie Jay on 25.10.17. +// Copyright © 2017 flowkey. All rights reserved. +// + +internal import SDL_gpu + +extension ShaderProgram { + static let gradient = try! GradientShaderProgram() +} + +final class GradientShaderProgram: ShaderProgram { + private var colorCount: UniformVariable! + private var gradientColors: UniformVariable! + private var gradientLocations: UniformVariable! + + private var startPoint: UniformVariable! + private var endPoint: UniformVariable! + + // we only need one MaskShaderProgram, which we can / should treat as a singleton + fileprivate init() throws { + try super.init(vertexShader: .common, fragmentShader: .gradient) + colorCount = UniformVariable("colorCount", in: programRef) + gradientColors = UniformVariable("gradientColors", in: programRef) + gradientLocations = UniformVariable("gradientLocations", in: programRef) + + startPoint = UniformVariable("startPoint", in: programRef) // in [0,1] layer space + endPoint = UniformVariable("endPoint", in: programRef) // in [0,1] layer space + } + + func set(colors: consuming [CGColor]) { + let count = min(8, colors.count) + colorCount.set(Int32(count)) + withUnsafeTemporaryAllocation(of: Float.self, capacity: count * 4, { ptr in + for i in 0 ..< count { + let outBaseIndex = i * 4 + ptr[outBaseIndex + 0] = Float(colors[i].redValue) / 255.0 + ptr[outBaseIndex + 1] = Float(colors[i].greenValue) / 255.0 + ptr[outBaseIndex + 2] = Float(colors[i].blueValue) / 255.0 + ptr[outBaseIndex + 3] = Float(colors[i].alphaValue) / 255.0 + } + + gradientColors.set(ptr) + }) + } + + func set(locations: consuming [Float]) { + gradientLocations.set(locations[0 ..< min(8, locations.count)]) + } +} + +extension FragmentShader { + static let gradient = try! FragmentShader(source: """ + \(`in`) vec4 originalColour; + \(`in`) vec2 absolutePixelPos; + + \(fragColorDefinition) + + const int MAX_COLORS = 8; // arbitrary but 'should be enough' + + uniform int colorCount; + uniform vec4 gradientColors[MAX_COLORS]; + uniform float gradientLocations[MAX_COLORS]; // values in [0,1] + + uniform vec2 startPoint; // in [0,1] layer space + uniform vec2 endPoint; // in [0,1] layer space + + vec4 sampleGradient(float t) { + t = clamp(t, 0.0, 1.0); + + // Handle outside explicit locations: use first / last + if (t <= gradientLocations[0]) { + return gradientColors[0]; + } + if (t >= gradientLocations[colorCount - 1]) { + return gradientColors[colorCount - 1]; + } + + // Find the two stops [i, i+1] that contain t + for (int i = 0; i < MAX_COLORS - 1; ++i) { + if (i >= colorCount - 1) { + break; + } + + float loc0 = gradientLocations[i]; + float loc1 = gradientLocations[i + 1]; + + if (t >= loc0 && t <= loc1) { + float localT = (t - loc0) / (loc1 - loc0); + return mix(gradientColors[i], gradientColors[i + 1], localT); + } + } + + // Fallback (shouldn't happen if above logic is correct) + return gradientColors[colorCount - 1]; + } + + void main() { + // p is the point in 'layer space' [0,1]×[0,1] + vec2 p = (absolutePixelPos * 0.5) + 0.5; + + // Match iOS's Y-down coordinates: + p.y = 1.0 - p.y; + + // Direction of gradient line + vec2 dir = endPoint - startPoint; + float len2 = dot(dir, dir); + + float t = 0.0; + if (len2 > 0.00001) { + // Project (p - startPoint) onto the gradient direction + t = dot(p - startPoint, dir) / len2; + } + + t = clamp(t, 0.0, 1.0); + + \(fragColor) = sampleGradient(t); + } + """ + ) +} diff --git a/Sources/ShaderProgram+mask.swift b/Sources/ShaderProgram+mask.swift index 05f00052..2763c835 100644 --- a/Sources/ShaderProgram+mask.swift +++ b/Sources/ShaderProgram+mask.swift @@ -9,22 +9,60 @@ internal import SDL_gpu extension ShaderProgram { - static let mask = try! MaskShaderProgram() + static let maskCompositor = try! MaskCompositing() } -class MaskShaderProgram: ShaderProgram { - private var maskFrame: UniformVariable! +class MaskCompositing: ShaderProgram { private var maskTexture: UniformVariable! // we only need one MaskShaderProgram, which we can / should treat as a singleton fileprivate init() throws { try super.init(vertexShader: .common, fragmentShader: .maskColourWithImage) - maskFrame = UniformVariable("maskFrame", in: programRef) maskTexture = UniformVariable("maskTexture", in: programRef) } - func set(maskImage: CGImage, frame: CGRect) { - maskFrame.set(frame) + func set(maskImage: CGImage) { maskTexture.set(maskImage) } } + +extension VertexShader { + static let common = try! VertexShader(source: + """ + \(`in`) vec3 gpu_Vertex; + \(`in`) vec2 gpu_TexCoord; + \(`in`) vec4 gpu_Color; + uniform mat4 gpu_ModelViewProjectionMatrix; + + \(`out`) vec4 color; + \(`out`) vec2 texCoord; + + void main(void) + { + color = gpu_Color; + texCoord = vec2(gpu_TexCoord); + gl_Position = gpu_ModelViewProjectionMatrix * vec4(gpu_Vertex, 1.0); + } + """ + ) +} + +extension FragmentShader { + static let maskColourWithImage = try! FragmentShader(source: """ + \(`in`) vec4 color; + \(`in`) vec2 texCoord; + + \(fragColorDefinition) + + uniform sampler2D tex; + uniform sampler2D maskTexture; + + void main(void) + { + vec4 base = \(texture)(tex, texCoord) * color; + float mask = \(texture)(maskTexture, texCoord).a; + \(fragColor) = vec4(base.rgb, base.a * mask); + } + """ + ) +} diff --git a/Sources/ShaderProgram.swift b/Sources/ShaderProgram.swift index 9961927a..ec2df83c 100644 --- a/Sources/ShaderProgram.swift +++ b/Sources/ShaderProgram.swift @@ -69,10 +69,32 @@ extension ShaderProgram { assert(location != -1, "Couldn't find location of UniformVariable \(name)") } + func set(_ newValue: Int32) { + GPU_SetUniformi(location, newValue) + } + func set(_ newValue: Float) { GPU_SetUniformf(location, newValue) } + func set(_ newValue: consuming ArraySlice) { + newValue.withUnsafeMutableBufferPointer({ self.set($0) }) + } + + func set(_ newValue: UnsafeMutableBufferPointer) { + GPU_SetUniformfv(location, Int32(newValue.count), 1, newValue.baseAddress) + } + + func set(_ newValue: CGPoint) { + var vals = [Float(newValue.x), Float(newValue.y)] + GPU_SetUniformfv(location, 2, 1, &vals) + } + + func set(_ newValue: CGSize) { + var vals = [Float(newValue.width), Float(newValue.height)] + GPU_SetUniformfv(location, 2, 1, &vals) + } + func set(_ newValue: CGRect) { var vals = [Float(newValue.minX), Float(newValue.minY), Float(newValue.height), Float(newValue.width)] GPU_SetUniformfv(location, 4, 1, &vals) diff --git a/Sources/UIScreen+render.swift b/Sources/UIScreen+render.swift index 82390885..bb65c5c2 100644 --- a/Sources/UIScreen+render.swift +++ b/Sources/UIScreen+render.swift @@ -22,7 +22,10 @@ extension UIScreen { // XXX: It's possible for drawing to crash if the context is invalid: window.sdlDrawAndLayoutTreeIfNeeded() - guard CALayer.layerTreeIsDirty else { + guard + CALayer.layerTreeIsDirty, + let mainRenderTarget = UIScreen.main?.renderTarget + else { // Nothing changed, so we can leave the existing image on the screen. return } @@ -31,122 +34,18 @@ extension UIScreen { // So set this here and only reset it if the .flip fails CALayer.layerTreeIsDirty = false - self.clear() + renderTarget.clear() GPU_MatrixMode(GPU_MODELVIEW) GPU_LoadIdentity() - self.clippingRect = window.bounds - window.layer.sdlRender() + renderTarget.clippingRect = window.bounds + window.layer.sdlRender(renderTarget: mainRenderTarget) do { - try self.flip() + try renderTarget.flip() } catch { CALayer.layerTreeIsDirty = true assertionFailure("UIScreen failed to render. This shouldn't happen anymore since we added more error handling when rendering the layer tree! Error: \(error)") } } - - func blit( - _ image: CGImage, - anchorPoint: CGPoint, - contentsScale: CGFloat, - contentsGravity: ContentsGravityTransformation, - opacity: Float - ) throws { - GPU_SetAnchor(image.rawPointer, Float(anchorPoint.x), Float(anchorPoint.y)) - GPU_SetRGBA(image.rawPointer, 255, 255, 255, opacity.normalisedToUInt8()) - - GPU_BlitTransform( - image.rawPointer, - nil, - self.rawPointer, - Float(contentsGravity.offset.x), - Float(contentsGravity.offset.y), - 0, // rotation in degrees - Float(contentsGravity.scale.width / contentsScale), - Float(contentsGravity.scale.height / contentsScale) - ) - - try throwOnErrors(ofType: [GPU_ERROR_USER_ERROR]) - } - - func setShapeBlending(_ newValue: Bool) { - GPU_SetShapeBlending(newValue) - } - - func setShapeBlendMode(_ newValue: GPU_BlendPresetEnum) { - GPU_SetShapeBlendMode(newValue) - } - - func clear() { - GPU_Clear(rawPointer) - } - - func fill(_ rect: CGRect, with color: UIColor, cornerRadius: CGFloat) { - if cornerRadius >= 1 { - GPU_RectangleRoundFilled(rawPointer, rect.gpuRect(scale: scale), cornerRadius: Float(cornerRadius), color: color.sdlColor) - } else { - GPU_RectangleFilled(rawPointer, rect.gpuRect(scale: scale), color: color.sdlColor) - } - } - - func outline(_ rect: CGRect, lineColor: UIColor, lineThickness: CGFloat) { - // we want to render the outline 'inside' the rect rather - // than exceeding the bounds when lineThickness is bigger than 1 - let offset = lineThickness / 2 - let scaledGpuRect = CGRect( - x: rect.origin.x + offset, - y: rect.origin.y + offset, - width: rect.size.width - offset, - height: rect.size.height - offset - ).gpuRect(scale: scale) - - GPU_SetLineThickness(Float(lineThickness)) - GPU_Rectangle(rawPointer, scaledGpuRect, color: lineColor.sdlColor) - } - - func outline(_ rect: CGRect, lineColor: UIColor, lineThickness: CGFloat, cornerRadius: CGFloat) { - if cornerRadius > 1 { - // we want to render the outline 'inside' the rect rather - // than exceeding the bounds when lineThickness is bigger than 1 - let offset = lineThickness / 2 - let scaledGpuRect = CGRect( - x: rect.origin.x + offset, - y: rect.origin.y + offset, - width: rect.size.width - offset, - height: rect.size.height - offset - ).gpuRect(scale: scale) - - GPU_SetLineThickness(Float(lineThickness)) - GPU_RectangleRound(rawPointer, scaledGpuRect, cornerRadius: Float(cornerRadius), color: lineColor.sdlColor) - } else { - outline(rect, lineColor: lineColor, lineThickness: lineThickness) - } - } - - func flip() throws { - GPU_Flip(rawPointer) - try throwOnErrors(ofType: [GPU_ERROR_USER_ERROR, GPU_ERROR_BACKEND_ERROR]) - } - - // Called when clippingRect was set. - // The function is separated out because that stored property can't be in this extension. - func didSetClippingRect() { - guard let clippingRect = clippingRect else { - return GPU_UnsetClip(rawPointer) - } - - GPU_SetClipRect(rawPointer, clippingRect.gpuRect(scale: scale)) - } -} - -private extension CGRect { - func gpuRect(scale: CGFloat) -> GPU_Rect { - return GPU_Rect( - x: Float((self.origin.x * scale).rounded() / scale), - y: Float((self.origin.y * scale).rounded() / scale), - w: Float((self.size.width * scale).rounded() / scale), - h: Float((self.size.height * scale).rounded() / scale) - ) - } } diff --git a/Sources/UIScreen.swift b/Sources/UIScreen.swift index ae2d35ab..58db7ccc 100644 --- a/Sources/UIScreen.swift +++ b/Sources/UIScreen.swift @@ -22,20 +22,20 @@ public extension UIScreen { public final class UIScreen { // If we could use `private` members from an extension this would be private // Keep that in mind when using it: i.e. if possible, don't ;) - internal var rawPointer: UnsafeMutablePointer! + internal var renderTarget: RenderTarget public var bounds: CGRect { didSet { let newWidth = UInt16(bounds.width.rounded()) let newHeight = UInt16(bounds.height.rounded()) GPU_SetWindowResolution(newWidth, newHeight) - GPU_SetVirtualResolution(rawPointer, newWidth, newHeight) + renderTarget.setVirtualResolution(w: newWidth, h: newHeight) } } nonisolated public let scale: CGFloat - private init(renderTarget: UnsafeMutablePointer!, bounds: CGRect, scale: CGFloat) { - self.rawPointer = renderTarget + private init(renderTarget: RenderTarget, bounds: CGRect, scale: CGFloat) { + self.renderTarget = renderTarget self.bounds = bounds self.scale = scale } @@ -58,7 +58,7 @@ public final class UIScreen { var size = CGSize.zero let options: SDLWindowFlags = SDL_WINDOW_FULLSCREEN #else - var size = CGSize.samsungGalaxyJ5.landscape + var size = CGSize.samsungGalaxyS5.landscape let options: SDLWindowFlags = [ SDL_WINDOW_ALLOW_HIGHDPI, @@ -104,18 +104,21 @@ public final class UIScreen { preconditionFailure("You need window dimensions to run") } + let renderTarget = RenderTarget(gpuTarget) + renderTarget.scale = scale + self.init( - renderTarget: gpuTarget, + renderTarget: renderTarget, bounds: CGRect(origin: .zero, size: size), scale: scale ) // Fixes video surface visibility with transparent & opaque views in SDLSurface above // by changing the alpha blend function to: src-alpha * (1 - dst-alpha) + dst-alpha - setShapeBlending(true) - setShapeBlendMode(GPU_BLEND_NORMAL_FACTOR_ALPHA) + renderTarget.setShapeBlending(true) + renderTarget.setShapeBlendMode(GPU_BLEND_NORMAL_FACTOR_ALPHA) - clearErrors() // by now we have handled any errors we might have wanted to + renderTarget.clearErrors() // by now we have handled any errors we might have wanted to } deinit { @@ -125,26 +128,17 @@ public final class UIScreen { UIView.currentAnimationPrototype = nil UIEvent.activeEvents.removeAll() FontRenderer.cleanupSession() - } - guard let rawPointer = self.rawPointer else { - return - } + defer { GPU_Quit() } + guard let gpuContext = renderTarget.rawPointer.pointee.context else { + assertionFailure("glRenderer gpuContext not found") + return + } - defer { GPU_Quit() } - guard let gpuContext = rawPointer.pointee.context else { - assertionFailure("glRenderer gpuContext not found") - return + let existingWindowID = gpuContext.pointee.windowID + let existingWindow = SDL_GetWindowFromID(existingWindowID) + SDL_DestroyWindow(existingWindow) } - - let existingWindowID = gpuContext.pointee.windowID - let existingWindow = SDL_GetWindowFromID(existingWindowID) - SDL_DestroyWindow(existingWindow) - } - - // Should be in UIScreen+render.swift but you can't store properties in an extension.. - var clippingRect: CGRect? { - didSet { didSetClippingRect() } } } @@ -165,7 +159,7 @@ extension UIScreen { import class AppKit.NSWindow extension UIScreen { var nsWindow: NSWindow { - let sdlWindowID = rawPointer.pointee.context.pointee.windowID + let sdlWindowID = renderTarget.rawPointer.pointee.context.pointee.windowID let sdlWindow = SDL_GetWindowFromID(sdlWindowID) var info = SDL_SysWMinfo() @@ -208,8 +202,9 @@ extension UIScreen { bounds: CGRect = CGRect(origin: .zero, size: .samsungGalaxyTab10), scale: CGFloat ) -> UIScreen { + let img = GPU_CreateImage(UInt16(bounds.width), UInt16(bounds.height), GPU_FORMAT_RGBA)! return UIScreen( - renderTarget: nil, + renderTarget: RenderTarget(image: img), bounds: bounds, scale: scale ) diff --git a/Sources/UIView+SDL.swift b/Sources/UIView+SDL.swift index 4b7523c5..2d07980c 100644 --- a/Sources/UIView+SDL.swift +++ b/Sources/UIView+SDL.swift @@ -25,9 +25,13 @@ extension UIView { needsDisplay = false } + if visibleLayer.mask?.needsDisplay() == true { + visibleLayer.mask?.display() + visibleLayer.mask?._needsDisplay = false + } + layoutIfNeeded() subviews.forEach { $0.sdlDrawAndLayoutTreeIfNeeded(parentAlpha: alpha) } } } - diff --git a/UIKit.xcodeproj/project.pbxproj b/UIKit.xcodeproj/project.pbxproj index 51b8d2e3..fcd3f1c7 100644 --- a/UIKit.xcodeproj/project.pbxproj +++ b/UIKit.xcodeproj/project.pbxproj @@ -47,7 +47,7 @@ 5C27E7E41ECB2F9100F5020D /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27E7DA1ECB2F9100F5020D /* ApplicationServices.framework */; }; 5C27E7E51ECB2F9100F5020D /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27E7DB1ECB2F9100F5020D /* Foundation.framework */; }; 5C27E80F1ECB301900F5020D /* SDL2-Shims.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C27E7FC1ECB301900F5020D /* SDL2-Shims.swift */; }; - 5C32F2C420A1C69C006D64C5 /* UIScreen+Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C32F2C320A1C69C006D64C5 /* UIScreen+Errors.swift */; }; + 5C32F2C420A1C69C006D64C5 /* RenderTarget+Errors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C32F2C320A1C69C006D64C5 /* RenderTarget+Errors.swift */; }; 5C361E231ECC348700B3F561 /* UIView+SDL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C361E221ECC348700B3F561 /* UIView+SDL.swift */; }; 5C3716B61F10050600B2C366 /* Timer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3716B51F10050600B2C366 /* Timer.swift */; }; 5C3DCCEF2090F072001DEDAF /* UIAlertControllerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3DCCEE2090F072001DEDAF /* UIAlertControllerView.swift */; }; @@ -59,9 +59,9 @@ 5C4CD51120615FCF00217B4C /* UIApplication+handleSDLEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4CD51020615FCF00217B4C /* UIApplication+handleSDLEvents.swift */; }; 5C4CD51A206160EC00217B4C /* UIView+printViewHierarchy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4CD519206160EC00217B4C /* UIView+printViewHierarchy.swift */; }; 5C5095B020C59303002E9DDD /* UIPanGestureRecognizerTests+draggableViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5095AD20C54ECC002E9DDD /* UIPanGestureRecognizerTests+draggableViews.swift */; }; - 5C664CD41FA0DBFC008F41D6 /* MaskingShaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C664CD31FA0DBFC008F41D6 /* MaskingShaders.swift */; }; 5C664CD61FA0DF1D008F41D6 /* Shader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C664CD51FA0DF1D008F41D6 /* Shader.swift */; }; 5C664CD81FA0E640008F41D6 /* ShaderProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C664CD71FA0E640008F41D6 /* ShaderProgram.swift */; }; + 5C67749C2EEB19250020F88B /* RenderTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C67749B2EEB19250020F88B /* RenderTarget.swift */; }; 5C6AB7181ED3BECD006F90AC /* Button.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AB7101ED3BECD006F90AC /* Button.swift */; }; 5C6AB7191ED3BECD006F90AC /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AB7111ED3BECD006F90AC /* CALayer.swift */; }; 5C6AB71B1ED3BECD006F90AC /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AB7131ED3BECD006F90AC /* UIColor.swift */; }; @@ -79,6 +79,7 @@ 5C6AB77E1ED71867006F90AC /* CATransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AB77D1ED71867006F90AC /* CATransaction.swift */; }; 5C6AB7801EDBDF40006F90AC /* UIViewTests+hitTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AB77F1EDBDF40006F90AC /* UIViewTests+hitTest.swift */; }; 5C6AB7821EDC18BA006F90AC /* UITouch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AB7811EDC18BA006F90AC /* UITouch.swift */; }; + 5C72F0312EE3874B00C2874B /* CAGradientLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C72F0302EE3874B00C2874B /* CAGradientLayer.swift */; }; 5C8475D7203DC6E700CA2EBF /* UIViewTests+convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8475CD203DC4CC00CA2EBF /* UIViewTests+convert.swift */; }; 5C8475D8203DC6EE00CA2EBF /* UIViewTests+misc.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8475D5203DC53200CA2EBF /* UIViewTests+misc.swift */; }; 5C8475D9203DCA3C00CA2EBF /* UIViewTests+convert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8475CD203DC4CC00CA2EBF /* UIViewTests+convert.swift */; }; @@ -253,7 +254,7 @@ 5C27E7DA1ECB2F9100F5020D /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 5C27E7DB1ECB2F9100F5020D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; 5C27E7FC1ECB301900F5020D /* SDL2-Shims.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SDL2-Shims.swift"; sourceTree = ""; }; - 5C32F2C320A1C69C006D64C5 /* UIScreen+Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIScreen+Errors.swift"; sourceTree = ""; }; + 5C32F2C320A1C69C006D64C5 /* RenderTarget+Errors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RenderTarget+Errors.swift"; sourceTree = ""; }; 5C361E221ECC348700B3F561 /* UIView+SDL.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+SDL.swift"; sourceTree = ""; }; 5C3716B51F10050600B2C366 /* Timer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timer.swift; sourceTree = ""; }; 5C3DCCEE2090F072001DEDAF /* UIAlertControllerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIAlertControllerView.swift; sourceTree = ""; }; @@ -267,9 +268,9 @@ 5C4CD51020615FCF00217B4C /* UIApplication+handleSDLEvents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+handleSDLEvents.swift"; sourceTree = ""; }; 5C4CD519206160EC00217B4C /* UIView+printViewHierarchy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+printViewHierarchy.swift"; sourceTree = ""; }; 5C5095AD20C54ECC002E9DDD /* UIPanGestureRecognizerTests+draggableViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIPanGestureRecognizerTests+draggableViews.swift"; sourceTree = ""; }; - 5C664CD31FA0DBFC008F41D6 /* MaskingShaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaskingShaders.swift; sourceTree = ""; }; 5C664CD51FA0DF1D008F41D6 /* Shader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shader.swift; sourceTree = ""; }; 5C664CD71FA0E640008F41D6 /* ShaderProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShaderProgram.swift; sourceTree = ""; }; + 5C67749B2EEB19250020F88B /* RenderTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderTarget.swift; sourceTree = ""; }; 5C6AB7101ED3BECD006F90AC /* Button.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Button.swift; path = Sources/Button.swift; sourceTree = SOURCE_ROOT; }; 5C6AB7111ED3BECD006F90AC /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CALayer.swift; path = Sources/CALayer.swift; sourceTree = SOURCE_ROOT; }; 5C6AB7131ED3BECD006F90AC /* UIColor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UIColor.swift; path = Sources/UIColor.swift; sourceTree = SOURCE_ROOT; }; @@ -287,6 +288,7 @@ 5C6AB77D1ED71867006F90AC /* CATransaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = CATransaction.swift; path = Sources/CATransaction.swift; sourceTree = SOURCE_ROOT; }; 5C6AB77F1EDBDF40006F90AC /* UIViewTests+hitTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewTests+hitTest.swift"; sourceTree = ""; }; 5C6AB7811EDC18BA006F90AC /* UITouch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = UITouch.swift; path = Sources/UITouch.swift; sourceTree = SOURCE_ROOT; }; + 5C72F0302EE3874B00C2874B /* CAGradientLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CAGradientLayer.swift; sourceTree = ""; }; 5C8475CD203DC4CC00CA2EBF /* UIViewTests+convert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewTests+convert.swift"; sourceTree = ""; }; 5C8475D5203DC53200CA2EBF /* UIViewTests+misc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewTests+misc.swift"; sourceTree = ""; }; 5C8E6A20203A089A00D1DBE0 /* CALayer+ContentsGravity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CALayer+ContentsGravity.swift"; sourceTree = ""; }; @@ -493,6 +495,7 @@ 5BBEECA21F39E770006E3416 /* Animations */, 5C6AB7101ED3BECD006F90AC /* Button.swift */, 5CAF2D0F1EE67DA2004C6380 /* CAAction.swift */, + 5C72F0302EE3874B00C2874B /* CAGradientLayer.swift */, 5C6AB7111ED3BECD006F90AC /* CALayer.swift */, 5C8E6A20203A089A00D1DBE0 /* CALayer+ContentsGravity.swift */, 5C6AB77D1ED71867006F90AC /* CATransaction.swift */, @@ -508,6 +511,7 @@ 5C9037241F138B8A0058E592 /* MeteringView.swift */, 61C9D3B821B052750012018B /* Notification.swift */, 61C9D3BA21B052B30012018B /* NotificationCenter.swift */, + 5C67749B2EEB19250020F88B /* RenderTarget.swift */, 8337A8581F3B143400475F80 /* UIActivityIndicatorView.swift */, 83AB7B4B1F2B515B00C7997D /* UIAlertAction.swift */, 5C3DCCED2090F04A001DEDAF /* UIAlertController */, @@ -615,7 +619,7 @@ 5C2714A11F2A3FC50026BBA9 /* SDL+JNIExtensions.swift */, 5C27E7FC1ECB301900F5020D /* SDL2-Shims.swift */, 5C3716B51F10050600B2C366 /* Timer.swift */, - 5C32F2C320A1C69C006D64C5 /* UIScreen+Errors.swift */, + 5C32F2C320A1C69C006D64C5 /* RenderTarget+Errors.swift */, 5C361E221ECC348700B3F561 /* UIView+SDL.swift */, ); name = SDL; @@ -635,7 +639,6 @@ 5C664CD11FA0DBDF008F41D6 /* Shaders */ = { isa = PBXGroup; children = ( - 5C664CD31FA0DBFC008F41D6 /* MaskingShaders.swift */, 5C664CD51FA0DF1D008F41D6 /* Shader.swift */, 5C664CD71FA0E640008F41D6 /* ShaderProgram.swift */, 5CDC14CB1FA0ED2E007CDCA3 /* ShaderProgram+mask.swift */, @@ -933,12 +936,12 @@ 5B8053DF1F39EE0E00BAF074 /* UIViewAnimationGroup.swift in Sources */, 5C93AEAA208A1541005BE853 /* UINavigationItem.swift in Sources */, 5CE5C57A1EDDAE5000680154 /* CGPath.swift in Sources */, - 5C664CD41FA0DBFC008F41D6 /* MaskingShaders.swift in Sources */, 5C418DBA20F505FF005D0E92 /* UIApplicationDelegate.swift in Sources */, 5B520DB5208A0AD5007F5075 /* FontRenderer+renderAttributedString.swift in Sources */, 5CAF2D101EE67DA2004C6380 /* CAAction.swift in Sources */, 83E5F8171EF8322E00279C59 /* CGFloat.swift in Sources */, 5C6AB71D1ED3BECD006F90AC /* UIImageView.swift in Sources */, + 5C72F0312EE3874B00C2874B /* CAGradientLayer.swift in Sources */, 5BBEECA51F39E7A6006E3416 /* CGRect+animations.swift in Sources */, 5B71D7472292B68E008CEC66 /* UIAccessibilityIdentification.swift in Sources */, 5CDC14CC1FA0ED2E007CDCA3 /* ShaderProgram+mask.swift in Sources */, @@ -998,6 +1001,7 @@ 5CBEB3E42914A75C00D0CC3F /* Data.swift in Sources */, 5C6AB77C1ED70F97006F90AC /* DisplayLink.swift in Sources */, 83E5F8161EF8322B00279C59 /* CGPoint.swift in Sources */, + 5C67749C2EEB19250020F88B /* RenderTarget.swift in Sources */, 83E5F7F61EF81AB500279C59 /* UIFont.swift in Sources */, 5C6AB71B1ED3BECD006F90AC /* UIColor.swift in Sources */, 5B6AC4571F2F73A90029AC0C /* CALayer+animations.swift in Sources */, @@ -1007,7 +1011,7 @@ 5C3DCCEF2090F072001DEDAF /* UIAlertControllerView.swift in Sources */, 61C9D3BB21B052B30012018B /* NotificationCenter.swift in Sources */, 5CE5C5781EDDAD8200680154 /* CGRect.swift in Sources */, - 5C32F2C420A1C69C006D64C5 /* UIScreen+Errors.swift in Sources */, + 5C32F2C420A1C69C006D64C5 /* RenderTarget+Errors.swift in Sources */, 0371D2291F793E5F005DDFED /* UIScrollView+velocity.swift in Sources */, 83AB7B5A1F2B679600C7997D /* UIPinchGestureRecognizer.swift in Sources */, 5C93AEA62088FB51005BE853 /* UINavigationBarAndroid.swift in Sources */, From eff0e98dfa77937696ddf37f0d109dc546a711a1 Mon Sep 17 00:00:00 2001 From: Geordie Jay Date: Fri, 12 Dec 2025 16:51:15 +0100 Subject: [PATCH 2/6] Update Xcode version for CircleCI --- circle.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/circle.yml b/circle.yml index 26d0b85e..aaf522c3 100644 --- a/circle.yml +++ b/circle.yml @@ -5,7 +5,7 @@ orbs: jobs: build: macos: - xcode: "16.1.0" + xcode: "26.2.0" steps: - macos/install-rosetta - checkout From cb7aa6cc2130cf98eff3973323f6eb49556ac6d9 Mon Sep 17 00:00:00 2001 From: Geordie Jay Date: Fri, 12 Dec 2025 16:56:41 +0100 Subject: [PATCH 3/6] Remove x64_64 requirement --- circle.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/circle.yml b/circle.yml index aaf522c3..70449455 100644 --- a/circle.yml +++ b/circle.yml @@ -19,7 +19,6 @@ jobs: CODE_SIGN_IDENTITY="" PROVISIONING_PROFILE="" -sdk "macosx" - -arch "x86_64" -scheme "UIKit" build test | xcpretty --color --report junit From 13cd830ee443a1f3402922be7d926f6542f8f184 Mon Sep 17 00:00:00 2001 From: Geordie Jay Date: Fri, 12 Dec 2025 16:59:57 +0100 Subject: [PATCH 4/6] Fix tests --- Sources/CGImage.swift | 2 +- Sources/UIScreen+render.swift | 4 ++-- Sources/UIScreen.swift | 16 +++++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/CGImage.swift b/Sources/CGImage.swift index 189d4cb8..03299725 100644 --- a/Sources/CGImage.swift +++ b/Sources/CGImage.swift @@ -24,7 +24,7 @@ public class CGImage { // We check for GPU errors on render, so clear any error that may have caused GPU_Image to be nil. // It's possible there are unrelated errors on the stack at this point, but we immediately catch and // handle any errors that interest us *when they occur*, so it's fine to clear unrelated ones here. - Task { @MainActor in UIScreen.main?.renderTarget.clearErrors() } + Task { @MainActor in UIScreen.main?.renderTarget?.clearErrors() } return nil } diff --git a/Sources/UIScreen+render.swift b/Sources/UIScreen+render.swift index bb65c5c2..2d986a1e 100644 --- a/Sources/UIScreen+render.swift +++ b/Sources/UIScreen+render.swift @@ -24,7 +24,7 @@ extension UIScreen { guard CALayer.layerTreeIsDirty, - let mainRenderTarget = UIScreen.main?.renderTarget + let renderTarget else { // Nothing changed, so we can leave the existing image on the screen. return @@ -39,7 +39,7 @@ extension UIScreen { GPU_LoadIdentity() renderTarget.clippingRect = window.bounds - window.layer.sdlRender(renderTarget: mainRenderTarget) + window.layer.sdlRender(renderTarget: renderTarget) do { try renderTarget.flip() diff --git a/Sources/UIScreen.swift b/Sources/UIScreen.swift index 58db7ccc..696caadb 100644 --- a/Sources/UIScreen.swift +++ b/Sources/UIScreen.swift @@ -22,19 +22,19 @@ public extension UIScreen { public final class UIScreen { // If we could use `private` members from an extension this would be private // Keep that in mind when using it: i.e. if possible, don't ;) - internal var renderTarget: RenderTarget + internal var renderTarget: RenderTarget? public var bounds: CGRect { didSet { let newWidth = UInt16(bounds.width.rounded()) let newHeight = UInt16(bounds.height.rounded()) GPU_SetWindowResolution(newWidth, newHeight) - renderTarget.setVirtualResolution(w: newWidth, h: newHeight) + renderTarget?.setVirtualResolution(w: newWidth, h: newHeight) } } nonisolated public let scale: CGFloat - private init(renderTarget: RenderTarget, bounds: CGRect, scale: CGFloat) { + private init(renderTarget: RenderTarget?, bounds: CGRect, scale: CGFloat) { self.renderTarget = renderTarget self.bounds = bounds self.scale = scale @@ -130,7 +130,7 @@ public final class UIScreen { FontRenderer.cleanupSession() defer { GPU_Quit() } - guard let gpuContext = renderTarget.rawPointer.pointee.context else { + guard let gpuContext = renderTarget?.rawPointer.pointee.context else { assertionFailure("glRenderer gpuContext not found") return } @@ -159,7 +159,10 @@ extension UIScreen { import class AppKit.NSWindow extension UIScreen { var nsWindow: NSWindow { - let sdlWindowID = renderTarget.rawPointer.pointee.context.pointee.windowID + guard let sdlWindowID = renderTarget?.rawPointer.pointee.context.pointee.windowID else { + return NSWindow() + } + let sdlWindow = SDL_GetWindowFromID(sdlWindowID) var info = SDL_SysWMinfo() @@ -202,9 +205,8 @@ extension UIScreen { bounds: CGRect = CGRect(origin: .zero, size: .samsungGalaxyTab10), scale: CGFloat ) -> UIScreen { - let img = GPU_CreateImage(UInt16(bounds.width), UInt16(bounds.height), GPU_FORMAT_RGBA)! return UIScreen( - renderTarget: RenderTarget(image: img), + renderTarget: nil, bounds: bounds, scale: scale ) From 56dcbec429a8bff2da18062249058c7e8115a18f Mon Sep 17 00:00:00 2001 From: Geordie Jay Date: Fri, 12 Dec 2025 17:33:46 +0100 Subject: [PATCH 5/6] Update iPhone --- circle.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/circle.yml b/circle.yml index 70449455..06c2acb8 100644 --- a/circle.yml +++ b/circle.yml @@ -31,7 +31,7 @@ jobs: CODE_SIGN_IDENTITY="" PROVISIONING_PROFILE="" -sdk "iphonesimulator" - -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' + -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17' -scheme "UIKit iOSTestTarget" test | xcpretty --color --report junit @@ -44,7 +44,7 @@ jobs: CODE_SIGN_IDENTITY="" PROVISIONING_PROFILE="" -sdk "iphonesimulator" - -destination 'platform=iOS Simulator,OS=latest,name=iPhone 16' + -destination 'platform=iOS Simulator,OS=latest,name=iPhone 17' -scheme "DemoApp" build | xcpretty --color --report junit From d8a7f91e631bdd51e843f7c64ba2c5d78abda457 Mon Sep 17 00:00:00 2001 From: Geordie Jay Date: Fri, 12 Dec 2025 19:18:58 +0100 Subject: [PATCH 6/6] Fix Android build --- SDL | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SDL b/SDL index 2056081c..bda3805d 160000 --- a/SDL +++ b/SDL @@ -1 +1 @@ -Subproject commit 2056081cd9815112c002fea5836d8fd933d2b757 +Subproject commit bda3805dc6c8e95b939d23d8b583266947cb5df3