Skip to content

Instantly share code, notes, and snippets.

@Koshimizu-Takehito
Last active December 4, 2025 07:00
Show Gist options
  • Select an option

  • Save Koshimizu-Takehito/f4cc94a1ab6dec7cd8b111a6509f5b6d to your computer and use it in GitHub Desktop.

Select an option

Save Koshimizu-Takehito/f4cc94a1ab6dec7cd8b111a6509f5b6d to your computer and use it in GitHub Desktop.
CRTEffect
import AVFoundation
import CoreImage
import SwiftUI
struct ContentView: View {
@State private var crtEffect = true
@State private var start = Date.now
@State private var provider = VideoImageProvider(
url: URL(string: "https://devstreaming-cdn.apple.com/videos/wwdc/2025/219/4/476dabc8-f3bb-4190-8929-4ba7131c939d/downloads/wwdc2025-219_hd.mp4")!
)
var body: some View {
TimelineView(.animation, content: screen(context:))
.ignoresSafeArea()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay(content: control)
.onAppear(perform: provider.resume)
.onDisappear(perform: provider.pause)
.tint(.blue)
}
@ViewBuilder
private func screen(context: TimelineViewDefaultContext) -> some View {
if let image = provider.image {
Image(decorative: image, scale: 1)
.resizable()
.scaledToFit()
.layerEffect(shader(context.date), maxSampleOffset: .zero)
}
}
@ViewBuilder
private func control() -> some View {
HStack {
let buttonName = provider.isPaused ? "play" : "pause"
Button(buttonName.capitalized, systemImage: buttonName, action: provider.toggle)
Spacer()
Toggle("CRT Effect", isOn: $crtEffect.animation())
.fixedSize()
.shadow(color: .black, radius: 2)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
.padding()
.fontWeight(.black)
.buttonStyle(.glass)
}
private func shader(_ date: Date) -> Shader {
let time = date.timeIntervalSince(start)
.truncatingRemainder(dividingBy: 2.0 * .pi)
let function = crtEffect ? ShaderLibrary.crtEffect : ShaderLibrary.id
return function(.float(10 * time), .boundingRect)
}
}
@MainActor
@Observable
final class VideoImageProvider {
private(set) var image: CGImage?
private(set) var isPaused: Bool = false
private let context = CIContext()
private let url: URL
@ObservationIgnored private var player: AVPlayer?
@ObservationIgnored private var output: AVPlayerItemVideoOutput!
@ObservationIgnored private var observer: NSKeyValueObservation?
@ObservationIgnored private lazy var displayLink = CADisplayLink(
target: self,
selector: #selector(copyPixelBuffers(link:))
)
init(url: URL) {
self.url = url
}
isolated deinit {
player?.pause()
observer?.invalidate()
}
func pause() {
player?.pause()
isPaused = true
}
func resume() {
guard let player else {
return start()
}
player.play()
isPaused = false
}
func toggle() {
isPaused ? resume() : pause()
}
private func start() {
let item = AVPlayerItem(url: url)
player = AVPlayer(playerItem: item)
let output = AVPlayerItemVideoOutput(outputSettings: [
AVVideoAllowWideColorKey: true,
AVVideoColorPropertiesKey: [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_P3_D65,
AVVideoTransferFunctionKey: AVVideoTransferFunction_Linear,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_2020,
],
kCVPixelBufferPixelFormatTypeKey as String: NSNumber(
value: kCVPixelFormatType_64RGBAHalf
),
])
self.output = output
self.observer = item.observe(\.status, options: [.new, .old], changeHandler: { item, _ in
guard item.status == .readyToPlay else {
return
}
item.add(output)
MainActor.assumeIsolated {
self.displayLink.add(to: .main, forMode: .common)
self.resume()
}
})
}
@objc private func copyPixelBuffers(link: CADisplayLink) {
let time = output.itemTime(forHostTime: CACurrentMediaTime())
if output.hasNewPixelBuffer(forItemTime: time), let buffer = output.copyPixelBuffer(forItemTime: time, itemTimeForDisplay: nil) {
let image = CIImage(cvPixelBuffer: buffer)
self.image = context.createCGImage(image, from: image.extent)
}
}
}
#Preview {
ContentView()
}
#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>
using namespace metal;
// 画面の曲面歪みを適用する関数
// この関数は、CRTモニターの曲面ガラスによる画像の歪みをシミュレートします。
// UV座標(0.0〜1.0の正規化座標)を入力とし、中央からの距離に基づいて
// バレル歪み(barrel distortion)を適用します。これにより、画面の端が
// 外側に膨張するような効果が生まれ、レトロなCRTの視覚を再現します。
// - Parameters:
// - uv: 正規化されたテクスチャ座標 (0.0〜1.0)。
// - strength: 歪みの強度。値が大きいほど歪みが強くなります(例: 0.3で軽めの曲面)。
// - Returns: 歪み適用後のUV座標。
float2 distort(float2 uv, float strength) {
// 中央からの距離ベクトルを計算(中央が0.5, 0.5)。
float2 dist = 0.5 - uv;
// X座標の歪み: Y方向の距離の2乗をX方向の距離に掛け、強度を適用。
// これにより、縦方向の曲がり具合が横方向に影響を与えます。
uv.x = (uv.x - dist.y * dist.y * dist.x * strength);
// Y座標の歪み: X方向の距離の2乗をY方向の距離に掛け、強度を適用。
// 同様に、横方向の曲がり具合が縦方向に影響を与えます。
uv.y = (uv.y - dist.x * dist.x * dist.y * strength);
// 歪み後のUV座標を返す。これをサンプリングに使用することで画像が曲がって見えます。
return uv;
}
// メインのCRTエフェクトシェーダ
[[stitchable]]
half4 crtEffect(float2 position, SwiftUI::Layer layer, float time, float4 bounds) {
float2 size = bounds.zw;
float2 uv = position / size;
uv = distort(uv, 0.1); // 曲面強度:
half4 col = layer.sample(float2(uv.x, uv.y) * size);
// 明るさ調整 (緑を強調してCRTの蛍光体を模倣)
col *= half4(0.95, 1.05, 0.95, 1);
col *= 2.8;
// スキャンライン追加 (時間とY座標で動的に変化)
float scans = clamp(0.35 + 0.35 * sin(time + uv.y * size.y * 1.5), 0.0, 1.0);
float s = pow(scans, 1.7);
float sn = 0.4 + 0.7 * s;
col = col * half4(sn, sn, sn, 1);
// 色調の時間ベース調整 (フリッカー効果)
col *= 1.0 + 0.01 * sin(110.0 * time);
// 水平ノイズ/フリッカー (ピクセルごとに明るさを変動)
float c = clamp((fmod(uv.x, 2.0) - 1.0) * 2.0, 0.0, 1.0);
col *= 1.0 - 0.65 * half4(c, c, c, 1);
return col;
}
[[stitchable]]
half4 id(float2 position, SwiftUI::Layer layer, float time, float4 bounds) {
return layer.sample(position);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment