Skip to content

Instantly share code, notes, and snippets.

@jsmmth
Created July 13, 2025 15:46
Show Gist options
  • Save jsmmth/b2d741901ff6f9bd9ac2533bd5c5b625 to your computer and use it in GitHub Desktop.
Save jsmmth/b2d741901ff6f9bd9ac2533bd5c5b625 to your computer and use it in GitHub Desktop.
Pretty messy, probably quite un-optimised fun SwiftUI star field experiment.
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