Created
July 13, 2025 15:46
-
-
Save jsmmth/b2d741901ff6f9bd9ac2533bd5c5b625 to your computer and use it in GitHub Desktop.
Pretty messy, probably quite un-optimised fun SwiftUI star field experiment.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import SwiftUI | |
import MetalKit | |
import simd | |
// MARK: - Metal View | |
struct MetalStarfieldView: UIViewRepresentable { | |
@Binding var speed: Double | |
@Binding var cameraShake: CGSize | |
let hyperdriveCallback: () -> Void | |
func makeUIView(context: Context) -> MTKView { | |
let metalView = MTKView() | |
guard let device = MTLCreateSystemDefaultDevice() else { | |
print("Metal is not supported on this device") | |
return metalView | |
} | |
metalView.device = device | |
metalView.backgroundColor = .clear | |
metalView.isOpaque = false | |
metalView.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) | |
metalView.delegate = context.coordinator | |
metalView.enableSetNeedsDisplay = false | |
metalView.isPaused = false | |
metalView.preferredFramesPerSecond = 60 | |
metalView.drawableSize = metalView.bounds.size | |
metalView.contentScaleFactor = UIScreen.main.scale | |
return metalView | |
} | |
func updateUIView(_ uiView: MTKView, context: Context) { | |
context.coordinator.speed = Float(speed) | |
context.coordinator.cameraOffset = SIMD2<Float>(Float(cameraShake.width), Float(cameraShake.height)) | |
} | |
func makeCoordinator() -> Coordinator { | |
Coordinator(speed: Float(speed), hyperdriveCallback: hyperdriveCallback) | |
} | |
class Coordinator: NSObject, MTKViewDelegate { | |
var device: MTLDevice? | |
var commandQueue: MTLCommandQueue? | |
var pipelineState: MTLRenderPipelineState? | |
var streakPipelineState: MTLRenderPipelineState? | |
var vertexBuffer: MTLBuffer? | |
var uniformBuffer: MTLBuffer? | |
var stars: [Star] = [] | |
var speed: Float = 1.0 | |
var lastUpdateTime: TimeInterval = 0 | |
var hyperdriveTransitionTime: Float = 0 | |
var wasInHyperdrive = false | |
var isInitialized = false | |
var cameraOffset = SIMD2<Float>(0, 0) | |
var rippleTime: Float = 0 | |
let hyperdriveCallback: () -> Void | |
struct Star { | |
var position: SIMD3<Float> | |
var initialPosition: SIMD3<Float> | |
var size: Float | |
var alpha: Float | |
var velocity: SIMD3<Float> | |
var brightness: Float | |
var twinkle: Float | |
var color: SIMD3<Float> | |
} | |
struct Uniforms { | |
var viewportSize: SIMD2<Float> | |
var time: Float | |
var speed: Float | |
var hyperdriveTransition: Float | |
var cameraOffset: SIMD2<Float> | |
var rippleTime: Float | |
var aspectRatio: Float | |
} | |
struct Vertex { | |
var position: SIMD3<Float> | |
var size: Float | |
var alpha: Float | |
var velocity: SIMD3<Float> | |
var brightness: Float | |
var twinkle: Float | |
var color: SIMD3<Float> | |
} | |
let starCount = 5000 | |
init(speed: Float, hyperdriveCallback: @escaping () -> Void) { | |
self.speed = speed | |
self.hyperdriveCallback = hyperdriveCallback | |
super.init() | |
} | |
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { | |
if !isInitialized && size.width > 0 && size.height > 0 { | |
setupMetal(for: view) | |
} | |
} | |
func setupMetal(for view: MTKView) { | |
guard let device = view.device else { return } | |
self.device = device | |
self.commandQueue = device.makeCommandQueue() | |
let shaderSource = """ | |
#include <metal_stdlib> | |
using namespace metal; | |
struct Uniforms { | |
float2 viewportSize; | |
float time; | |
float speed; | |
float hyperdriveTransition; | |
float2 cameraOffset; | |
float rippleTime; | |
float aspectRatio; | |
}; | |
struct VertexIn { | |
float3 position; | |
float size; | |
float alpha; | |
float3 velocity; | |
float brightness; | |
float twinkle; | |
float3 color; | |
}; | |
struct VertexOut { | |
float4 position [[position]]; | |
float pointSize [[point_size]]; | |
float alpha; | |
float speed; | |
float3 worldPos; | |
float hyperdriveTransition; | |
float2 screenPos; | |
float3 velocity; | |
float brightness; | |
float twinkle; | |
float3 color; | |
float glowIntensity; | |
}; | |
struct StreakVertexOut { | |
float4 position [[position]]; | |
float alpha; | |
float speed; | |
float hyperdriveTransition; | |
float3 color; | |
float brightness; | |
float2 texCoord; | |
float edgeFade; | |
}; | |
vertex VertexOut starfield_vertex(uint vertexID [[vertex_id]], | |
const device VertexIn* vertices [[buffer(0)]], | |
const device Uniforms& uniforms [[buffer(1)]]) { | |
VertexIn star = vertices[vertexID]; | |
float fov = 60.0 * 3.14159 / 180.0; | |
float focalLength = uniforms.viewportSize.y / (2.0 * tan(fov * 0.5)); | |
float scale = focalLength / star.position.z; | |
float2 screenPos = star.position.xy * scale + uniforms.viewportSize * 0.5; | |
float twinkleAmount = sin(uniforms.time * 3.0 + star.twinkle * 6.28318) * 0.5 + 0.5; | |
float brightnessModifier = mix(0.7, 1.0, twinkleAmount); | |
float ringBoost = 1.0; | |
if (uniforms.rippleTime > 0) { | |
float2 center = uniforms.viewportSize * 0.5; | |
float2 toCenter = screenPos - center; | |
float dist = length(toCenter); | |
float ringRadius = (1.0 - uniforms.rippleTime) * uniforms.viewportSize.x * 1.5; | |
float ringWidth = 100.0; | |
float ringDistance = abs(dist - ringRadius); | |
float ringIntensity = 1.0 - saturate(ringDistance / ringWidth); | |
if (ringIntensity > 0 && dist > 0) { | |
float2 dir = toCenter / dist; | |
float pushAmount = ringIntensity * 150.0 * uniforms.rippleTime; | |
screenPos += dir * pushAmount; | |
ringBoost = 1.0 + ringIntensity * 3.0; | |
} | |
} | |
screenPos += uniforms.cameraOffset; | |
VertexOut out; | |
out.position = float4((screenPos.x / uniforms.viewportSize.x) * 2.0 - 1.0, | |
1.0 - (screenPos.y / uniforms.viewportSize.y) * 2.0, | |
0.0, 1.0); | |
out.screenPos = screenPos; | |
if (uniforms.speed >= 5.0) { | |
out.pointSize = 0.0; | |
} else { | |
float baseSize = star.size * (1.0 + star.brightness * 0.5); | |
if (uniforms.speed < 1.0) { | |
out.pointSize = max(baseSize * 5.0, 1.5); | |
} else { | |
float distanceScale = 1.0 + (1.0 - star.position.z / 1200.0) * 3.0; | |
out.pointSize = max(baseSize * 4.0 * distanceScale, 1.5); | |
} | |
float speedGlow = smoothstep(2.0, 4.0, uniforms.speed); | |
out.pointSize *= (1.0 + speedGlow * 2.5); | |
out.pointSize = min(out.pointSize, 30.0); | |
} | |
out.glowIntensity = star.brightness * brightnessModifier * ringBoost; | |
out.alpha = star.alpha * ringBoost; | |
out.speed = uniforms.speed; | |
out.worldPos = star.position; | |
out.hyperdriveTransition = uniforms.hyperdriveTransition; | |
out.velocity = star.velocity; | |
out.brightness = star.brightness * brightnessModifier; | |
out.twinkle = star.twinkle; | |
out.color = star.color; | |
return out; | |
} | |
vertex StreakVertexOut streak_vertex(uint vertexID [[vertex_id]], | |
uint instanceID [[instance_id]], | |
const device VertexIn* stars [[buffer(0)]], | |
const device Uniforms& uniforms [[buffer(1)]]) { | |
VertexIn star = stars[instanceID]; | |
StreakVertexOut out; | |
// Show more streaks in hyperdrive - lower threshold | |
if (star.brightness < 0.15 || star.size < 0.4) { | |
out.position = float4(0, 0, -1, 1); | |
out.alpha = 0; | |
out.speed = uniforms.speed; | |
out.hyperdriveTransition = 0; | |
out.color = float3(0); | |
out.brightness = 0; | |
return out; | |
} | |
float focalLength = uniforms.viewportSize.y / (2.0 * tan(60.0 * 3.14159 / 360.0)); | |
float scale = focalLength / star.position.z; | |
float2 screenPos = star.position.xy * scale + uniforms.viewportSize * 0.5; | |
screenPos += uniforms.cameraOffset; | |
float2 velocity2D = star.velocity.xy * scale; | |
float velocityMagnitude = length(velocity2D); | |
float2 dir = velocityMagnitude > 0 ? normalize(velocity2D) : float2(0, 0); | |
if (velocityMagnitude < 0.001) { | |
float2 toCenter = screenPos - uniforms.viewportSize * 0.5; | |
float dist = length(toCenter); | |
dir = dist > 0 ? normalize(toCenter) : float2(1, 0); | |
} | |
float depthFactor = 1.0 - (star.position.z / 1500.0); | |
float baseLength = 150.0 + depthFactor * 200.0; | |
float speedFactor = smoothstep(4.5, 6.0, uniforms.speed); | |
float streakLength = baseLength * (0.3 + speedFactor * 0.7); | |
float deltaZ = uniforms.speed * 200.0 / 60.0; | |
float pastZ = star.position.z + deltaZ * 10.0; | |
float pastScale = focalLength / pastZ; | |
float2 pastScreenPos = star.position.xy * pastScale + uniforms.viewportSize * 0.5 + uniforms.cameraOffset; | |
float2 actualDir = screenPos - pastScreenPos; | |
float actualDist = length(actualDir); | |
if (actualDist > 0.001) { | |
dir = normalize(actualDir); | |
streakLength = min(actualDist, streakLength); | |
} | |
if (uniforms.rippleTime > 0) { | |
float2 center = uniforms.viewportSize * 0.5; | |
float2 toCenter = screenPos - center; | |
float dist = length(toCenter); | |
float ringRadius = (1.0 - uniforms.rippleTime) * uniforms.viewportSize.x * 1.5; | |
float ringWidth = 100.0; | |
float ringDistance = abs(dist - ringRadius); | |
float ringIntensity = 1.0 - saturate(ringDistance / ringWidth); | |
if (ringIntensity > 0 && dist > 0) { | |
float2 rippleDir = normalize(toCenter); | |
float pushAmount = ringIntensity * 150.0 * uniforms.rippleTime; | |
screenPos += rippleDir * pushAmount; | |
pastScreenPos += rippleDir * pushAmount * 0.7; | |
} | |
} | |
float2 perp = float2(-dir.y, dir.x); | |
float width = (1.5 + star.brightness * 2.0) * depthFactor; | |
uint localVertex = vertexID % 4; | |
float2 finalPos; | |
float t = 0.0; | |
if (localVertex == 0) { | |
finalPos = pastScreenPos + perp * width * 0.1; | |
t = 0.0; | |
} else if (localVertex == 1) { | |
finalPos = pastScreenPos - perp * width * 0.1; | |
t = 0.0; | |
} else if (localVertex == 2) { | |
finalPos = screenPos + perp * width; | |
t = 1.0; | |
} else { | |
finalPos = screenPos - perp * width; | |
t = 1.0; | |
} | |
out.position = float4((finalPos.x / uniforms.viewportSize.x) * 2.0 - 1.0, | |
1.0 - (finalPos.y / uniforms.viewportSize.y) * 2.0, | |
0.0, 1.0); | |
out.alpha = star.alpha * star.brightness; | |
out.speed = uniforms.speed; | |
out.hyperdriveTransition = uniforms.hyperdriveTransition; | |
out.color = star.color; | |
out.brightness = star.brightness; | |
out.texCoord = float2(t, 0); | |
out.edgeFade = 1.0; | |
return out; | |
} | |
fragment float4 starfield_fragment(VertexOut in [[stage_in]], | |
float2 pointCoord [[point_coord]], | |
const device Uniforms& uniforms [[buffer(0)]]) { | |
float2 coord = pointCoord * 2.0 - 1.0; | |
float dist = length(coord); | |
float innerCore = 1.0 - smoothstep(0.0, 0.2, dist); | |
float midGlow = 1.0 - smoothstep(0.1, 0.5, dist); | |
float outerGlow = 1.0 - smoothstep(0.3, 1.0, dist); | |
float glow = innerCore * 1.0 + midGlow * 0.5 + outerGlow * 0.2; | |
glow = pow(glow, 0.8); | |
float speedBoost = smoothstep(2.0, 4.0, uniforms.speed); | |
glow *= (1.0 + speedBoost * 0.5); | |
float3 color = in.color; | |
if (uniforms.speed > 3.0) { | |
float aberration = (uniforms.speed - 3.0) / 3.0; | |
color.r *= 1.0 + aberration * 0.1; | |
color.b *= 1.0 - aberration * 0.05; | |
} | |
float4 finalColor = float4(color, glow * in.alpha * in.glowIntensity); | |
if (in.hyperdriveTransition > 0) { | |
finalColor += float4(1.0, 1.0, 1.0, in.hyperdriveTransition * 0.5); | |
} | |
return finalColor; | |
} | |
fragment float4 streak_fragment(StreakVertexOut in [[stage_in]], | |
const device Uniforms& uniforms [[buffer(0)]]) { | |
if (in.brightness == 0) { | |
discard_fragment(); | |
} | |
float t = in.texCoord.x; | |
float innerCore = pow(t, 0.5); | |
float midGlow = pow(t, 1.2); | |
float outerGlow = pow(t, 2.0); | |
float glow = innerCore * 0.6 + midGlow * 0.3 + outerGlow * 0.1; | |
glow *= in.brightness; | |
float3 color = in.color; | |
if (in.brightness > 0.8) { | |
float3 coreColor = mix(color, float3(0.95, 0.97, 1.0), 0.3 * t); | |
color = mix(color, coreColor, innerCore); | |
} | |
if (t > 0.5) { | |
float aberration = (t - 0.5) * 0.1; | |
color.r *= 1.0 + aberration; | |
color.b *= 1.0 - aberration * 0.5; | |
} | |
float3 finalColor = color * glow * 2.5; | |
if (glow > 0.7) { | |
float bloom = (glow - 0.7) * 3.0; | |
finalColor += color * bloom * 0.5; | |
} | |
float ghost = smoothstep(0.0, 0.3, t) * (1.0 - smoothstep(0.7, 1.0, t)); | |
finalColor += color * ghost * 0.3; | |
float alpha = glow * in.alpha * 2.0; | |
if (t > 0.8) { | |
alpha *= 1.2; | |
} | |
if (in.hyperdriveTransition > 0) { | |
float3 transitionColor = mix(color, float3(0.7, 0.8, 1.0), 0.5); | |
finalColor += transitionColor * in.hyperdriveTransition * 0.5; | |
alpha += in.hyperdriveTransition * 0.3; | |
} | |
float twinkle = sin(uniforms.time * 4.0 + in.brightness * 6.28) * 0.05 + 1.0; | |
finalColor *= twinkle; | |
return float4(finalColor, min(alpha, 1.0)); | |
} | |
""" | |
do { | |
let library = try device.makeLibrary(source: shaderSource, options: nil) | |
guard let vertexFunction = library.makeFunction(name: "starfield_vertex"), | |
let fragmentFunction = library.makeFunction(name: "starfield_fragment"), | |
let streakVertexFunction = library.makeFunction(name: "streak_vertex"), | |
let streakFragmentFunction = library.makeFunction(name: "streak_fragment") else { | |
print("Failed to load shader functions") | |
return | |
} | |
let pipelineDescriptor = MTLRenderPipelineDescriptor() | |
pipelineDescriptor.vertexFunction = vertexFunction | |
pipelineDescriptor.fragmentFunction = fragmentFunction | |
pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat | |
pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true | |
pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha | |
pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .one | |
pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha | |
pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .one | |
pipelineDescriptor.colorAttachments[0].writeMask = [.red, .green, .blue] | |
pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor) | |
let streakPipelineDescriptor = MTLRenderPipelineDescriptor() | |
streakPipelineDescriptor.vertexFunction = streakVertexFunction | |
streakPipelineDescriptor.fragmentFunction = streakFragmentFunction | |
streakPipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat | |
streakPipelineDescriptor.colorAttachments[0].isBlendingEnabled = true | |
streakPipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha | |
streakPipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .one | |
streakPipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha | |
streakPipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .one | |
streakPipelineDescriptor.colorAttachments[0].writeMask = [.red, .green, .blue] | |
streakPipelineState = try device.makeRenderPipelineState(descriptor: streakPipelineDescriptor) | |
uniformBuffer = device.makeBuffer(length: MemoryLayout<Uniforms>.stride, options: []) | |
generateStars() | |
isInitialized = true | |
} catch { | |
print("Failed to create pipeline state: \(error)") | |
} | |
} | |
func generateStars() { | |
guard let device = device else { return } | |
stars.removeAll() | |
for _ in 0..<starCount { | |
let distributionType = Float.random(in: 0...1) | |
let x: Float | |
let y: Float | |
if distributionType < 0.3 { | |
let angle = Float.random(in: 0...(2 * .pi)) | |
let radius = Float.random(in: 300...1200) | |
x = cos(angle) * radius | |
y = sin(angle) * radius | |
} else { | |
x = Float.random(in: -1200...1200) | |
y = Float.random(in: -900...900) | |
} | |
let z = Float.random(in: 10...1500) | |
let starType = Float.random(in: 0...1) | |
let brightness: Float | |
let sizeMultiplier: Float | |
if starType < 0.4 { | |
brightness = Float.random(in: 0.2...0.5) | |
sizeMultiplier = Float.random(in: 0.3...1.0) | |
} else if starType < 0.7 { | |
brightness = Float.random(in: 0.5...0.8) | |
sizeMultiplier = Float.random(in: 1.0...2.0) | |
} else if starType < 0.9 { | |
brightness = Float.random(in: 0.8...1.0) | |
sizeMultiplier = Float.random(in: 2.0...4.0) | |
} else { | |
brightness = 1.0 | |
sizeMultiplier = Float.random(in: 4.0...6.0) | |
} | |
let colorVariation = Float.random(in: 0...1) | |
let color: SIMD3<Float> | |
if colorVariation < 0.6 { | |
color = SIMD3<Float>(1.0, 1.0, 1.0) | |
} else if colorVariation < 0.75 { | |
let blueAmount = Float.random(in: 0.05...0.15) | |
color = SIMD3<Float>(1.0 - blueAmount, 1.0 - blueAmount * 0.5, 1.0) | |
} else if colorVariation < 0.88 { | |
let yellowAmount = Float.random(in: 0.05...0.15) | |
color = SIMD3<Float>(1.0, 1.0 - yellowAmount * 0.3, 1.0 - yellowAmount) | |
} else if colorVariation < 0.96 { | |
color = SIMD3<Float>(1.0, Float.random(in: 0.85...0.95), Float.random(in: 0.7...0.85)) | |
} else { | |
color = SIMD3<Float>(1.0, Float.random(in: 0.7...0.85), Float.random(in: 0.6...0.75)) | |
} | |
let dimmedColor = color * (0.7 + brightness * 0.3) | |
let star = Star( | |
position: SIMD3<Float>(x, y, z), | |
initialPosition: SIMD3<Float>(x, y, z), | |
size: 1.2 * sizeMultiplier, | |
alpha: 1.0, | |
velocity: SIMD3<Float>(0, 0, -200), | |
brightness: brightness, | |
twinkle: Float.random(in: 0...1), | |
color: dimmedColor | |
) | |
stars.append(star) | |
} | |
updateVertexBuffer() | |
} | |
func updateVertexBuffer() { | |
guard let device = device else { return } | |
var vertices: [Vertex] = [] | |
for star in stars { | |
let vertex = Vertex( | |
position: star.position, | |
size: star.size, | |
alpha: star.alpha, | |
velocity: star.velocity, | |
brightness: star.brightness, | |
twinkle: star.twinkle, | |
color: star.color | |
) | |
vertices.append(vertex) | |
} | |
vertexBuffer = device.makeBuffer(bytes: vertices, | |
length: vertices.count * MemoryLayout<Vertex>.stride, | |
options: []) | |
} | |
func draw(in view: MTKView) { | |
guard isInitialized, | |
let commandQueue = commandQueue, | |
let pipelineState = pipelineState, | |
let streakPipelineState = streakPipelineState, | |
let vertexBuffer = vertexBuffer, | |
let uniformBuffer = uniformBuffer else { | |
if !isInitialized && view.bounds.size.width > 0 { | |
setupMetal(for: view) | |
} | |
return | |
} | |
let currentTime = CACurrentMediaTime() | |
let deltaTime = lastUpdateTime == 0 ? 0.016 : currentTime - lastUpdateTime | |
lastUpdateTime = currentTime | |
let isInHyperdrive = speed >= 5.0 | |
if !wasInHyperdrive && isInHyperdrive { | |
hyperdriveTransitionTime = 1.0 | |
rippleTime = 1.0 | |
hyperdriveCallback() | |
} | |
wasInHyperdrive = isInHyperdrive | |
if hyperdriveTransitionTime > 0 { | |
hyperdriveTransitionTime -= Float(deltaTime) * 3.0 | |
hyperdriveTransitionTime = max(0, hyperdriveTransitionTime) | |
} | |
if rippleTime > 0 { | |
rippleTime -= Float(deltaTime) * 3.33 | |
rippleTime = max(0, rippleTime) | |
} | |
updateStars(deltaTime: Float(deltaTime)) | |
guard let drawable = view.currentDrawable, | |
let renderPassDescriptor = view.currentRenderPassDescriptor else { | |
return | |
} | |
guard let commandBuffer = commandQueue.makeCommandBuffer(), | |
let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { | |
return | |
} | |
let aspectRatio = Float(view.bounds.width / view.bounds.height) | |
var uniforms = Uniforms( | |
viewportSize: SIMD2<Float>(Float(view.bounds.width), Float(view.bounds.height)), | |
time: Float(currentTime), | |
speed: speed, | |
hyperdriveTransition: hyperdriveTransitionTime, | |
cameraOffset: cameraOffset, | |
rippleTime: rippleTime, | |
aspectRatio: aspectRatio | |
) | |
uniformBuffer.contents().copyMemory(from: &uniforms, byteCount: MemoryLayout<Uniforms>.stride) | |
if speed >= 5.0 { | |
renderEncoder.setRenderPipelineState(streakPipelineState) | |
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) | |
renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1) | |
renderEncoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 0) | |
renderEncoder.drawPrimitives(type: .triangleStrip, | |
vertexStart: 0, | |
vertexCount: 4, | |
instanceCount: stars.count) | |
} else { | |
renderEncoder.setRenderPipelineState(pipelineState) | |
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) | |
renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1) | |
renderEncoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 0) | |
renderEncoder.drawPrimitives(type: .point, vertexStart: 0, vertexCount: stars.count) | |
} | |
renderEncoder.endEncoding() | |
commandBuffer.present(drawable) | |
commandBuffer.commit() | |
} | |
func updateStars(deltaTime: Float) { | |
for i in 0..<stars.count { | |
let parallaxSpeed = 1.0 - (stars[i].position.z / 1200.0) * 0.5 | |
let deltaZ = deltaTime * 200 * speed * parallaxSpeed | |
stars[i].position.z -= deltaZ | |
if stars[i].position.z <= 0.1 { | |
stars[i].position.z = Float.random(in: 1000...1200) | |
let distributionType = Float.random(in: 0...1) | |
if distributionType < 0.3 { | |
let angle = Float.random(in: 0...(2 * .pi)) | |
let radius = Float.random(in: 300...1200) | |
stars[i].position.x = cos(angle) * radius | |
stars[i].position.y = sin(angle) * radius | |
} else { | |
stars[i].position.x = Float.random(in: -1200...1200) | |
stars[i].position.y = Float.random(in: -900...900) | |
} | |
stars[i].initialPosition.x = stars[i].position.x | |
stars[i].initialPosition.y = stars[i].position.y | |
} | |
let baseSize = 300 / stars[i].position.z | |
stars[i].size = min(max(baseSize, 0.3), 10.0) | |
if speed >= 5.0 { | |
stars[i].alpha = 1.0 | |
} else { | |
let distanceFade = 1.0 - pow(stars[i].position.z / 1200.0, 1.5) | |
stars[i].alpha = max(0.15, distanceFade) | |
} | |
stars[i].velocity.z = -200 * speed * parallaxSpeed | |
} | |
updateVertexBuffer() | |
} | |
} | |
} | |
// MARK: - Main View | |
struct ContentView: View { | |
@State private var speed: Double = 1.0 | |
@State private var showHyperdriveFlash = false | |
@State private var cameraShake: CGSize = .zero | |
@State private var showBurstRing = false | |
@State private var burstRingScale: CGFloat = 1.0 | |
@State private var burstRingOpacity: Double = 0 | |
@State private var controlsShakeOffset: CGSize = .zero | |
@State private var gradientShakeOffset: CGSize = .zero | |
var body: some View { | |
ZStack { | |
LinearGradient( | |
gradient: Gradient(colors: [ | |
Color(red: 0.02, green: 0.02, blue: 0.05), | |
Color(red: 0.05, green: 0.02, blue: 0.1), | |
Color(red: 0.08, green: 0.03, blue: 0.15) | |
]), | |
startPoint: .topLeading, | |
endPoint: .bottomTrailing | |
) | |
.ignoresSafeArea() | |
.offset(gradientShakeOffset) | |
MetalLiquidGradientView( | |
blobCount: 4, | |
tightness: 1.45, | |
sharpness: 1.26, | |
warp1: 0.18, | |
warp2: 0.08, | |
warp3: 0.06, | |
colors: [.pink, .blue, .teal, .purple] | |
) | |
.opacity(0.3) | |
.ignoresSafeArea() | |
.offset(gradientShakeOffset) | |
MetalStarfieldView( | |
speed: $speed, | |
cameraShake: $cameraShake, | |
hyperdriveCallback: triggerHyperdriveBurst | |
) | |
.ignoresSafeArea() | |
if showBurstRing { | |
Circle() | |
.stroke( | |
LinearGradient( | |
gradient: Gradient(colors: [ | |
Color.white.opacity(0.8), | |
Color.blue.opacity(0.6), | |
Color.purple.opacity(0.4) | |
]), | |
startPoint: .topLeading, | |
endPoint: .bottomTrailing | |
), | |
lineWidth: 3 | |
) | |
.frame(width: 20, height: 20) | |
.scaleEffect(burstRingScale) | |
.opacity(burstRingOpacity) | |
.blendMode(.plusLighter) | |
} | |
if showHyperdriveFlash { | |
RadialGradient( | |
gradient: Gradient(colors: [ | |
Color.white.opacity(0.4), | |
Color.blue.opacity(0.2), | |
Color.clear | |
]), | |
center: .center, | |
startRadius: 0, | |
endRadius: UIScreen.main.bounds.width | |
) | |
.ignoresSafeArea() | |
.blendMode(.plusLighter) | |
} | |
VStack { | |
Spacer() | |
VStack(spacing: 20) { | |
Text("Speed: \(speedDescription)") | |
.font(.system(size: 16, weight: .semibold, design: .monospaced)) | |
.foregroundColor(.white) | |
.shadow(color: .black.opacity(0.5), radius: 5) | |
HStack(spacing: 20) { | |
Image(systemName: "tortoise.fill") | |
.foregroundColor(.white.opacity(0.7)) | |
.font(.system(size: 20)) | |
ZStack { | |
Capsule() | |
.fill(Color.white.opacity(0.1)) | |
.frame(height: 6) | |
Slider(value: $speed, in: 0.1...6.0) | |
.accentColor(speedColor) | |
} | |
Image(systemName: "bolt.fill") | |
.foregroundColor(.white.opacity(0.7)) | |
.font(.system(size: 20)) | |
} | |
.padding(.horizontal, 5) | |
} | |
.padding(25) | |
.background( | |
ZStack { | |
RoundedRectangle(cornerRadius: 20) | |
.fill(Color.black.opacity(0.3)) | |
.background( | |
VisualEffectBlur(blurStyle: .systemUltraThinMaterialDark) | |
.clipShape(RoundedRectangle(cornerRadius: 20)) | |
) | |
RoundedRectangle(cornerRadius: 20) | |
.stroke( | |
LinearGradient( | |
gradient: Gradient(colors: [ | |
Color.white.opacity(0.3), | |
Color.white.opacity(0.1) | |
]), | |
startPoint: .topLeading, | |
endPoint: .bottomTrailing | |
), | |
lineWidth: 1 | |
) | |
} | |
) | |
.padding(.horizontal, 30) | |
.padding(.bottom, 40) | |
.offset(controlsShakeOffset) | |
} | |
} | |
.preferredColorScheme(.dark) | |
} | |
var speedDescription: String { | |
switch speed { | |
case 0.1..<1.0: | |
return "Thrusters Idle" | |
case 1.0..<3.0: | |
return "Cruising Velocity" | |
case 3.0..<5.0: | |
return "Engaging Warp Drive" | |
case 5.0...6.0: | |
return "HYPERDRIVE ACTIVE" | |
default: | |
return "Unknown" | |
} | |
} | |
var speedColor: Color { | |
switch speed { | |
case 0.1..<3.0: | |
return .blue | |
case 3.0..<5.0: | |
return .purple | |
case 5.0...6.0: | |
return .pink | |
default: | |
return .blue | |
} | |
} | |
func triggerHyperdriveBurst() { | |
showBurstRing = true | |
burstRingScale = 1.0 | |
burstRingOpacity = 0.8 | |
withAnimation(.easeOut(duration: 0.5)) { | |
burstRingScale = 80.0 | |
burstRingOpacity = 0 | |
} | |
withAnimation(.easeIn(duration: 0.15)) { | |
showHyperdriveFlash = true | |
} | |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { | |
withAnimation(.easeOut(duration: 0.35)) { | |
showHyperdriveFlash = false | |
} | |
} | |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { | |
showBurstRing = false | |
burstRingScale = 1.0 | |
} | |
// performCameraShake() | |
} | |
// func performCameraShake() { | |
// let shakeAmount: CGFloat = 40 | |
// let numberOfShakes = 8 | |
// let shakeDuration = 0.04 | |
// | |
// for i in 0..<numberOfShakes { | |
// DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * shakeDuration * 2) { | |
// withAnimation(.easeInOut(duration: shakeDuration)) { | |
// let offset = CGSize( | |
// width: CGFloat.random(in: -shakeAmount...shakeAmount), | |
// height: CGFloat.random(in: -shakeAmount...shakeAmount) | |
// ) | |
// | |
// controlsShakeOffset = offset | |
// gradientShakeOffset = offset | |
// } | |
// } | |
// | |
// DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * shakeDuration * 2 + shakeDuration) { | |
// withAnimation(.easeInOut(duration: shakeDuration)) { | |
// controlsShakeOffset = .zero | |
// gradientShakeOffset = .zero | |
// } | |
// } | |
// } | |
// } | |
} | |
struct VisualEffectBlur: UIViewRepresentable { | |
var blurStyle: UIBlurEffect.Style | |
func makeUIView(context: Context) -> UIVisualEffectView { | |
UIVisualEffectView(effect: UIBlurEffect(style: blurStyle)) | |
} | |
func updateUIView(_ uiView: UIVisualEffectView, context: Context) { | |
uiView.effect = UIBlurEffect(style: blurStyle) | |
} | |
} | |
// MARK: - Liquid Gradient Background | |
struct MetalLiquidGradientView: UIViewRepresentable { | |
var blobCount: Int | |
var tightness: Float | |
var sharpness: Float | |
var warp1: Float | |
var warp2: Float | |
var warp3: Float | |
var colors: [Color] | |
func makeCoordinator() -> LiquidCoordinator { | |
LiquidCoordinator(blobCount: blobCount, | |
tightness: tightness, | |
sharpness: sharpness, | |
warp1: warp1, | |
warp2: warp2, | |
warp3: warp3, | |
colors: colors) | |
} | |
func makeUIView(context: Context) -> MTKView { | |
context.coordinator.view | |
} | |
func updateUIView(_ uiView: MTKView, context: Context) { | |
context.coordinator.updateConfig( | |
blobCount: blobCount, | |
tightness: tightness, | |
sharpness: sharpness, | |
warp1: warp1, | |
warp2: warp2, | |
warp3: warp3, | |
colors: colors.map { | |
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0 | |
UIColor($0).getRed(&r, green: &g, blue: &b, alpha: nil) | |
return SIMD3(Float(r), Float(g), Float(b)) | |
} | |
) | |
} | |
} | |
final class LiquidCoordinator: NSObject, MTKViewDelegate { | |
private let device: MTLDevice | |
private let commandQueue: MTLCommandQueue | |
private let pipeline: MTLRenderPipelineState | |
private var blobCount: Int | |
private var tightness: Float | |
private var sharpness: Float | |
private var warp1: Float | |
private var warp2: Float | |
private var warp3: Float | |
private var colors: [SIMD3<Float>] | |
struct Uniforms { | |
var time: Float | |
var resolution: SIMD2<Float> | |
var blobCount: UInt32 | |
var tightness: Float | |
var sharpness: Float | |
var warp1: Float | |
var warp2: Float | |
var warp3: Float | |
} | |
private let start = CFAbsoluteTimeGetCurrent() | |
let view: MTKView | |
init(blobCount: Int, tightness: Float, sharpness: Float, | |
warp1: Float, warp2: Float, warp3: Float, colors: [Color]) { | |
self.blobCount = blobCount | |
self.tightness = tightness | |
self.sharpness = sharpness | |
self.warp1 = warp1 | |
self.warp2 = warp2 | |
self.warp3 = warp3 | |
self.colors = colors.map { | |
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0 | |
UIColor($0).getRed(&r, green: &g, blue: &b, alpha: nil) | |
return SIMD3(Float(r), Float(g), Float(b)) | |
} | |
guard let device = MTLCreateSystemDefaultDevice() else { fatalError("No Metal device") } | |
self.device = device | |
view = MTKView(frame: .zero, device: device) | |
view.colorPixelFormat = .bgra8Unorm | |
view.framebufferOnly = false | |
view.preferredFramesPerSecond = 60 | |
view.clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 0) | |
view.isOpaque = false | |
view.backgroundColor = .clear | |
let library = try! device.makeLibrary(source: Self.metalSource, options: nil) | |
let pipelineDesc = MTLRenderPipelineDescriptor() | |
pipelineDesc.vertexFunction = library.makeFunction(name: "vertex_main") | |
pipelineDesc.fragmentFunction = library.makeFunction(name: "fragment_main") | |
pipelineDesc.colorAttachments[0].pixelFormat = view.colorPixelFormat | |
pipelineDesc.colorAttachments[0].isBlendingEnabled = true | |
pipelineDesc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha | |
pipelineDesc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha | |
pipelineDesc.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha | |
pipelineDesc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha | |
pipeline = try! device.makeRenderPipelineState(descriptor: pipelineDesc) | |
commandQueue = device.makeCommandQueue()! | |
super.init() | |
view.delegate = self | |
} | |
func draw(in view: MTKView) { | |
guard let drawable = view.currentDrawable, | |
let desc = view.currentRenderPassDescriptor, | |
let cmd = commandQueue.makeCommandBuffer(), | |
let enc = cmd.makeRenderCommandEncoder(descriptor: desc) else { return } | |
var u = Uniforms( | |
time: Float(CFAbsoluteTimeGetCurrent() - start), | |
resolution: SIMD2(Float(view.drawableSize.width), Float(view.drawableSize.height)), | |
blobCount: UInt32(blobCount), | |
tightness: tightness, | |
sharpness: sharpness, | |
warp1: warp1, | |
warp2: warp2, | |
warp3: warp3 | |
) | |
enc.setRenderPipelineState(pipeline) | |
enc.setVertexBytes(&u, length: MemoryLayout<Uniforms>.size, index: 1) | |
enc.setFragmentBytes(&u, length: MemoryLayout<Uniforms>.size, index: 1) | |
let maxColors = 64 | |
var colorArray = colors | |
if colorArray.count < blobCount { | |
colorArray += Array(repeating: SIMD3<Float>(1, 1, 1), count: blobCount - colorArray.count) | |
} | |
enc.setFragmentBytes(colorArray, length: maxColors * MemoryLayout<SIMD3<Float>>.size, index: 2) | |
enc.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4) | |
enc.endEncoding() | |
cmd.present(drawable) | |
cmd.commit() | |
} | |
func updateConfig(blobCount: Int, tightness: Float, sharpness: Float, | |
warp1: Float, warp2: Float, warp3: Float, colors: [SIMD3<Float>]) { | |
self.blobCount = blobCount | |
self.tightness = tightness | |
self.sharpness = sharpness | |
self.warp1 = warp1 | |
self.warp2 = warp2 | |
self.warp3 = warp3 | |
self.colors = colors | |
} | |
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {} | |
private static let metalSource = """ | |
#include <metal_stdlib> | |
using namespace metal; | |
struct Uniforms { | |
float time; | |
float2 resolution; | |
uint blobCount; | |
float tightness; | |
float sharpness; | |
float warp1; | |
float warp2; | |
float warp3; | |
}; | |
struct VSOut { | |
float4 pos [[position]]; | |
float2 uv; | |
}; | |
vertex VSOut vertex_main(uint vid [[vertex_id]], | |
constant Uniforms& u [[buffer(1)]]) { | |
float2 corners[4] = { {-1, 1}, {-1, -1}, {1, 1}, {1, -1} }; | |
VSOut out; | |
out.pos = float4(corners[vid], 0, 1); | |
out.uv = (corners[vid] + 1.0) * 0.5; | |
return out; | |
} | |
float2 hash22(float2 p) { | |
p = fract(p * float2(5.3983, 5.4427)); | |
p += dot(p, p.yx + 19.19); | |
return fract(float2(p.x * p.y, p.x + p.y)); | |
} | |
fragment float4 fragment_main(VSOut in [[stage_in]], | |
constant Uniforms& u [[buffer(1)]], | |
constant float3* blobColors [[buffer(2)]]) { | |
float2 uv = in.uv * 2.0 - 1.0; | |
float2 warped = uv | |
+ u.warp1 * float2(sin(uv.y * 2.0 + u.time * 0.65), cos(uv.x * 2.0 - u.time * 0.48)) | |
+ u.warp2 * float2(cos(uv.y * 3.3 - u.time * 0.45), sin(uv.x * 3.3 + u.time * 0.38)) | |
+ u.warp3 * float2(sin((uv.x + uv.y) * 2.4 + u.time * 0.55), | |
cos((uv.x - uv.y) * 2.4 - u.time * 0.43)); | |
float weightSum = 0.0; | |
float3 colorSum = float3(0.0); | |
for (uint i = 0; i < u.blobCount; ++i) { | |
float2 center = i < 4 | |
? float2((i & 1) == 0 ? -0.9 : 0.9, (i & 2) == 0 ? 0.9 : -0.9) | |
: (hash22(float2(i, 42.0)) * 1.8 - 0.9); | |
if (i >= 4) { | |
float angle = 6.2831 * hash22(float2(i, 42.0)).x; | |
center += 0.12 * float2(sin(u.time * 0.55 + angle), | |
cos(u.time * 0.44 + angle * 1.3)); | |
} | |
float w = exp(-dot(warped - center, warped - center) * u.tightness); | |
w = pow(w, u.sharpness); | |
float3 col = blobColors[i % u.blobCount]; | |
weightSum += w; | |
colorSum += w * col; | |
} | |
float3 rgb = colorSum / max(weightSum, 1e-4); | |
rgb = mix(rgb, float3(0.0), 0.01); | |
float alpha = min(weightSum * 0.3, 0.4); | |
return float4(rgb, alpha); | |
} | |
"""; | |
} | |
#Preview { | |
ContentView() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment