Skip to content

Instantly share code, notes, and snippets.

@Koshimizu-Takehito
Last active November 4, 2025 23:30
Show Gist options
  • Save Koshimizu-Takehito/0ddf118c3a9f99eebefaf06bff94e68d to your computer and use it in GitHub Desktop.
Save Koshimizu-Takehito/0ddf118c3a9f99eebefaf06bff94e68d to your computer and use it in GitHub Desktop.
Conway's Game of Life(コンウェイのライフゲーム)
#include <metal_stdlib>
using namespace metal;
// ============================================================================
// Compute: 1 step of Game of Life
// ============================================================================
/**
* @brief Perform a single step update of Conway's Game of Life.
*
* @param src Source grid (R8Uint). Cell value is taken from .r & 1u.
* @param dst Destination grid (R8Uint).
* @param wh1 Packed width (x), height (y), dummy (z).
* @param wrap Boundary mode. 0 = clamp, 1 = torus (wrap-around).
* @param gid Global thread position in the grid.
* @param ltid Local thread position in the threadgroup (unused).
* @param gsize Total grid size in threads (unused).
* @param lsize Threadgroup size (unused).
*
* The kernel reads the eight neighbors of each cell and writes the next state
* according to the Game of Life rules:
* - Live cell survives with 2 or 3 neighbors.
* - Dead cell becomes live with exactly 3 neighbors.
*/
kernel void lifeStep(
texture2d<uint, access::read> src [[texture(0)]],
texture2d<uint, access::write> dst [[texture(1)]],
constant uint3& wh1 [[buffer(0)]], // width, height, dummy
constant uint& wrap [[buffer(1)]], // 0: clamp, 1: torus
uint2 gid [[thread_position_in_grid]],
uint2 ltid [[thread_position_in_threadgroup]],
uint2 gsize [[threads_per_grid]],
uint2 lsize [[threads_per_threadgroup]]
) {
const uint W = wh1.x;
const uint H = wh1.y;
if (gid.x >= W || gid.y >= H) {
return;
}
// Neighbor reader (integer coordinates).
auto r = [&](int x, int y) -> uint {
int ix = x, iy = y;
if (wrap == 1) {
ix = (ix % int(W) + int(W)) % int(W);
iy = (iy % int(H) + int(H)) % int(H);
} else {
ix = clamp(ix, 0, int(W) - 1);
iy = clamp(iy, 0, int(H) - 1);
}
return src.read(uint2(ix, iy)).r & 1u;
};
const int x = int(gid.x);
const int y = int(gid.y);
uint s =
r(x - 1, y - 1) + r(x, y - 1) + r(x + 1, y - 1) +
r(x - 1, y ) + r(x + 1, y ) +
r(x - 1, y + 1) + r(x, y + 1) + r(x + 1, y + 1);
uint c = src.read(uint2(gid)).r & 1u;
uint n = (c == 1u) ? ((s == 2u || s == 3u) ? 1u : 0u)
: ((s == 3u) ? 1u : 0u);
dst.write(n, gid);
}
// ============================================================================
// Render: Fullscreen blit with zoom/pan
// ============================================================================
/**
* @brief Vertex output structure for fullscreen blit.
*/
struct VSOut {
float4 position [[position]];
float2 uv;
};
/**
* @brief Fullscreen triangle vertex shader (3-vertex trick).
*
* Generates positions/UVs to cover the whole screen without a vertex buffer.
*
* @param vid Vertex ID (0..2).
* @return Position/UV pair.
*/
vertex VSOut fullscreenQuadVS(uint vid [[vertex_id]]) {
// 3 頂点(三角形)でフルスクリーンを覆う(頂点バッファ不要)
float2 pos[3] = {
float2(-1.0, -1.0),
float2( 3.0, -1.0),
float2(-1.0, 3.0),
};
float2 uv[3] = {
float2(0.0, 0.0),
float2(2.0, 0.0),
float2(0.0, 2.0),
};
VSOut out;
out.position = float4(pos[vid], 0.0, 1.0);
out.uv = uv[vid];
return out;
}
/**
* @brief Per-view uniforms for blit and cell styling.
*
* @note
* - `pad` はセル内パディング(片側率)。0.0〜0.5 の範囲を想定。
*/
struct ViewUniforms {
uint width; ///< Grid width (cells)
uint height; ///< Grid height (cells)
float scale; ///< View scale (>= 1.0)
float2 location; ///< View center in normalized space (-0.5 .. 0.5)
float2 offset; ///< Additional offset (optional)
float4 foreground; ///< RGBA color for live cells
float4 background; ///< RGBA color for background
float pad; ///< Cell padding ratio per side (0.0 .. 0.5)
};
/**
* @brief Fragment shader for blitting the grid with zoom/pan and padding.
*
* @param in Vertex-to-fragment payload.
* @param grid R8Uint grid texture. Cell state is stored in .r LSB.
* @param U View uniforms.
* @return RGBA color.
*/
fragment float4 lifeBlitFS(
VSOut in [[stage_in]],
texture2d<uint> grid [[texture(0)]],
constant ViewUniforms& U [[buffer(0)]]
) {
float2 uv = in.uv - 0.5;
uv /= max(1.0, U.scale);
uv += 0.5 - U.location + U.offset;
float2 xy = uv * float2(U.width, U.height);
int2 ij = int2(floor(xy));
if (ij.x < 0 || ij.y < 0 || ij.x >= int(U.width) || ij.y >= int(U.height)) {
return U.background;
}
// Fractional coordinate in-cell (0..1).
float2 frac = fract(xy);
// Read live/dead state.
uint v = grid.read(uint2(ij)).r & 1u;
if (v == 1u) {
// 片側 U.pad(例: 0.05 = 5%)を余白として背景色で塗る。
if (frac.x < U.pad || frac.x > (1.0 - U.pad) ||
frac.y < U.pad || frac.y > (1.0 - U.pad)) {
return U.background;
}
return U.foreground;
} else {
// デッドセルは背景色。
return U.background;
}
}
import SwiftUI
// MARK: - GameOfLifeScreen
/// Conway のライフゲーム画面(メタル描画 + コントロール)。
struct GameOfLifeScreen: View {
@State private var viewModel = GameOfLifeViewModel()
var body: some View {
VStack(spacing: 24) {
Text("Number of Cycles: \(viewModel.numberOfCycles)")
.font(.headline)
.foregroundStyle(.secondary)
GeometryReader { geometry in
// Metal 描画
MetalGameOfLifeView(viewModel: viewModel)
.animation(.default, value: viewModel.scale)
.scaledToFit()
.overlay(alignment: .topLeading) {
// デバッグ表示
HStack(spacing: 12) {
Text("Size: \(viewModel.size)×\(viewModel.size)")
Text("Interval: \(Int(viewModel.cycleIntervalMS)) ms")
Text(viewModel.isRunning ? "Running" : "Stopped")
}
.monospaced()
.font(.caption)
.fontWeight(.semibold)
.padding(8)
.background(.thinMaterial, in: .rect(cornerRadius: 8))
.padding(8)
}
.simultaneousGesture(magnifyGesture)
.simultaneousGesture(panGesture(size: geometry.size))
}
.scaledToFit()
// 操作パネル
controls
.padding()
}
.monospacedDigit()
.navigationTitle("Conway's Game of Life")
.toolbar(content: toolbar)
.toolbarTitleDisplayMode(.inlineLarge)
.tint(.blue)
}
// MARK: Gestures (継続点を考慮)
/// ピンチズーム用ジェスチャ(継続点: `lastScale`)。
private var magnifyGesture: some Gesture {
MagnifyGesture()
.onChanged { value in
// 継続点: lastScale * magnification
viewModel.scale = max(1.0, viewModel.lastScale * value.magnification)
viewModel.clampLocation()
}
.onEnded { _ in
viewModel.lastScale = viewModel.scale
}
}
/// ドラッグ(パン)用ジェスチャ(継続点: `lastLocation`)。
/// - Parameter size: ジオメトリ(ピクセル幅・高さ)。
private func panGesture(size: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
let s = max(1.0, viewModel.scale)
let dx = (value.translation.width / size.width) / s
let dy = (value.translation.height / size.height) / s
viewModel.location = CGPoint(
x: viewModel.lastLocation.x + dx,
y: viewModel.lastLocation.y - dy
)
viewModel.clampLocation()
}
.onEnded { _ in
viewModel.lastLocation = viewModel.location
}
}
// MARK: Controls
/// 操作パネルビュー。
@ViewBuilder
private var controls: some View {
VStack(spacing: 12) {
Picker("Grid Size", selection: $viewModel.size) {
ForEach([64, 128, 256, 512, 1024], id: \.self) { n in
Text("\(n)").tag(n)
}
}
.disabled(viewModel.isRunning)
Slider(value: $viewModel.cycleIntervalMS, in: 3 ... 100, step: 1)
HStack(spacing: 16) {
Button {
viewModel.isRunning = true
} label: {
Label("Start", systemImage: "play.fill")
}
.disabled(viewModel.isRunning)
Button {
viewModel.isRunning = false
} label: {
Label("Stop", systemImage: "pause.fill")
}
.disabled(!viewModel.isRunning)
Button {
viewModel.isRunning = false
viewModel.resetStats()
viewModel.resetView()
let s = viewModel.size // 再初期化
viewModel.size = s
} label: {
Label("Reset", systemImage: "arrow.counterclockwise")
}
}
}
.pickerStyle(.segmented)
.buttonStyle(.borderedProminent)
.font(.body.weight(.semibold))
}
@ToolbarContentBuilder
private func toolbar() -> some ToolbarContent {
// ズーム用コントロール
ToolbarItemGroup(placement: .bottomBar) {
Button {
viewModel.decrementScale()
} label: {
Image(systemName: "minus.magnifyingglass")
}
.disabled(viewModel.scale == 1)
Button {
viewModel.resetView()
} label: {
Image(systemName: "1.magnifyingglass")
}
.disabled(viewModel.scale == 1)
Button {
viewModel.incrementScale()
} label: {
Image(systemName: "plus.magnifyingglass")
}
}
}
}
import Foundation
import Observation
// MARK: - GameOfLifeViewModel
/// Conway のライフゲーム表示・操作に関する状態を管理する ViewModel。
@MainActor
@Observable
final class GameOfLifeViewModel {
// MARK: Current View State
/// 拡大率(1.0 を既定とする)。
var scale: CGFloat = 1.0
/// 正規化空間上の表示中心位置。(-0.5 ... 0.5) を想定。
var location: CGPoint = .zero
/// 追加オフセット(必要に応じて利用)。
var offset: CGPoint = .zero
/// 自動ステップ実行フラグ。
var isRunning: Bool = false
/// 自動ステップの間隔(ミリ秒)。
var cycleIntervalMS: Double = 30.0
/// 盤面の一辺セル数。
/// 値変更時は `onSizeChanged` が呼ばれる。
var size: Int = 256 {
didSet {
onSizeChanged?(size)
}
}
// MARK: Gesture Anchors
/// ズーム操作の継続点(直前の拡大率)。
@ObservationIgnored var lastScale: CGFloat = 1.0
/// パン操作の継続点(直前の座標)。
@ObservationIgnored var lastLocation: CGPoint = .zero
// MARK: Metrics
/// 実行済みステップ数(描画用メトリクス)。
private(set) var numberOfCycles: Int = 0
// MARK: Renderer Hooks
/// サイズ変更時に呼ばれるフック。
@ObservationIgnored var onSizeChanged: ((Int) -> Void)?
/// ステップコミット後に呼ばれるフック。
@ObservationIgnored var onStepCommitted: (() -> Void)?
// MARK: Lifecycle
/// 1 ステップ進行が確定したことを通知する。
func didCommitStep() {
numberOfCycles += 1
onStepCommitted?()
}
/// 表示系メトリクスをリセットする。
func resetStats() {
numberOfCycles = 0
}
// MARK: Zoom Controls
/// 既定表示(1x・中央)へリセットする。
func resetView() {
scale = 1.0
lastScale = 1.0
location = .zero
lastLocation = .zero
offset = .zero
}
/// 段階的に拡大する(継続点も更新)。
func incrementScale() {
let new = max(1.0, scale * 1.5)
scale = new
lastScale = new
clampLocation()
}
/// 段階的に縮小する(継続点も更新、1x なら中央へ)。
func decrementScale() {
let new = max(1.0, scale / 1.5)
scale = new
lastScale = new
if new == 1.0 {
location = .zero
lastLocation = .zero
offset = .zero
} else {
clampLocation()
}
}
/// 現在の `scale` に基づいて `location` をクランプする。
func clampLocation() {
let s = max(1.0, scale)
let v = 0.5 - (1.0 / (2.0 * s)) // 表示領域の半径(正規化)
location.x = CGFloat(max(-v, min(v, Double(location.x))))
location.y = CGFloat(max(-v, min(v, Double(location.y))))
}
}
#Preview {
NavigationStack {
GameOfLifeScreen()
}
.colorScheme(.dark)
}
import Metal
import MetalKit
import Observation
import SwiftUI
import simd
// MARK: - MTKView Representable
/// Metal による Game of Life の描画ビュー(`UIViewRepresentable`)。
struct MetalGameOfLifeView: UIViewRepresentable {
// MARK: Coordinator
/// `MTKViewDelegate` 実装と Metal パイプラインの管理。
final class Coordinator: NSObject, MTKViewDelegate {
// MARK: Types
/// フラグメントシェーダへ送る表示用ユニフォーム。
struct ViewUniforms {
var width: UInt32
var height: UInt32
var scale: Float
var location: SIMD2<Float> // (-0.5...0.5)
var offset: SIMD2<Float>
var foreground: SIMD4<Float> // RGBA
var background: SIMD4<Float>
var pad: Float = 0.05
}
// MARK: Stored Properties
private let viewModel: GameOfLifeViewModel
let device: MTLDevice
private let queue: MTLCommandQueue
private var computePSO: MTLComputePipelineState!
private var renderPSO: MTLRenderPipelineState!
private var srcTex: MTLTexture!
private var dstTex: MTLTexture!
private var lastStepTime: CFTimeInterval = CACurrentMediaTime()
// MARK: Init
init(viewModel: GameOfLifeViewModel, device: MTLDevice) {
self.viewModel = viewModel
self.device = device
self.queue = device.makeCommandQueue()!
super.init()
buildPipelines()
makeTextures(size: viewModel.size)
// VM フック
viewModel.onSizeChanged = { [weak self] new in
self?.makeTextures(size: new)
}
}
// MARK: Setup
/// コンピュート/レンダーの各パイプラインを構築する。
private func buildPipelines() {
let library = try! device.makeDefaultLibrary(bundle: .main)
// Compute
let cs = library.makeFunction(name: "lifeStep")!
computePSO = try! device.makeComputePipelineState(function: cs)
// Render
let vs = library.makeFunction(name: "fullscreenQuadVS")!
let fs = library.makeFunction(name: "lifeBlitFS")!
let desc = MTLRenderPipelineDescriptor()
desc.vertexFunction = vs
desc.fragmentFunction = fs
desc.colorAttachments[0].pixelFormat = .bgra8Unorm
renderPSO = try! device.makeRenderPipelineState(descriptor: desc)
}
/// 指定サイズでピンポン用テクスチャを生成し、乱数初期化する。
/// - Parameter size: 一辺セル数。
private func makeTextures(size: Int) {
let d = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: .r8Uint,
width: size,
height: size,
mipmapped: false
)
d.usage = [.shaderRead, .shaderWrite]
srcTex = device.makeTexture(descriptor: d)
dstTex = device.makeTexture(descriptor: d)
randomize(texture: srcTex)
randomize(texture: dstTex) // 初期は同じでも OK
viewModel.resetStats()
}
/// R8Uint テクスチャを 0/1 でランダム初期化する。
private func randomize(texture: MTLTexture) {
let count = texture.width * texture.height
var bytes = [UInt8](repeating: 0, count: count)
for i in 0 ..< count {
bytes[i] = UInt8.random(in: 0 ... 1)
}
bytes.withUnsafeBytes { ptr in
texture.replace(
region: MTLRegionMake2D(0, 0, texture.width, texture.height),
mipmapLevel: 0,
withBytes: ptr.baseAddress!,
bytesPerRow: texture.width
)
}
}
// MARK: MTKViewDelegate
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
// NOP
}
func draw(in view: MTKView) {
guard
let drawable = view.currentDrawable,
let commandBuffer = queue.makeCommandBuffer()
else { return }
// 1) 必要なら 1 ステップ進める(間隔は cycleIntervalMS)
if viewModel.isRunning {
let now = CACurrentMediaTime()
let elapsedMS = (now - lastStepTime) * 1000.0
if elapsedMS >= viewModel.cycleIntervalMS {
encodeCompute(into: commandBuffer)
swap(&srcTex, &dstTex)
lastStepTime = now
viewModel.didCommitStep()
}
}
// 2) 描画
if let rp = view.currentRenderPassDescriptor {
encodeRender(into: commandBuffer, with: rp)
commandBuffer.present(drawable)
}
commandBuffer.commit()
}
// MARK: Encode
/// ライフゲームの 1 ステップを計算するコンピュートパスをエンコードする。
private func encodeCompute(into cb: MTLCommandBuffer) {
guard let enc = cb.makeComputeCommandEncoder() else { return }
enc.setComputePipelineState(computePSO)
enc.setTexture(srcTex, index: 0)
enc.setTexture(dstTex, index: 1)
var wh1 = SIMD3<UInt32>(UInt32(srcTex.width), UInt32(srcTex.height), 1)
var wrap: UInt32 = 1 // 1 にするとトーラス(ラップ)境界
enc.setBytes(&wh1, length: MemoryLayout<SIMD3<UInt32>>.stride, index: 0)
enc.setBytes(&wrap, length: MemoryLayout<UInt32>.stride, index: 1)
let w = computePSO.threadExecutionWidth
let h = max(1, computePSO.maxTotalThreadsPerThreadgroup / w)
let tg = MTLSize(width: w, height: h, depth: 1)
let ng = MTLSize(
width: (srcTex.width + w - 1) / w,
height: (srcTex.height + h - 1) / h,
depth: 1
)
enc.dispatchThreadgroups(ng, threadsPerThreadgroup: tg)
enc.endEncoding()
}
/// 表示(フルスクリーン・クアッド)のレンダーパスをエンコードする。
private func encodeRender(into cb: MTLCommandBuffer, with rpd: MTLRenderPassDescriptor) {
guard let enc = cb.makeRenderCommandEncoder(descriptor: rpd) else { return }
enc.setRenderPipelineState(renderPSO)
// uniforms
var uni = ViewUniforms(
width: UInt32(srcTex.width),
height: UInt32(srcTex.height),
scale: Float(max(1.0, viewModel.scale)),
location: SIMD2<Float>(Float(viewModel.location.x), Float(viewModel.location.y)),
offset: SIMD2<Float>(Float(viewModel.offset.x), Float(viewModel.offset.y)),
foreground: SIMD4<Float>(0.2, 0.9, 0.7, 1.0), // mint-ish
background: SIMD4<Float>(0.05, 0.05, 0.10, 1.0) // dark bluish
)
enc.setFragmentBytes(&uni, length: MemoryLayout<ViewUniforms>.stride, index: 0)
enc.setFragmentTexture(srcTex, index: 0)
// フルスクリーンクアッド(3頂点トリック)
enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
enc.endEncoding()
}
}
// MARK: UIViewRepresentable
/// ViewModel 参照。
let viewModel: GameOfLifeViewModel
func makeCoordinator() -> Coordinator {
let device = MTLCreateSystemDefaultDevice()!
return Coordinator(viewModel: viewModel, device: device)
}
func makeUIView(context: Context) -> MTKView {
let device = context.coordinator.device
let view = MTKView(frame: .zero, device: device)
view.enableSetNeedsDisplay = false
view.isPaused = false
view.framebufferOnly = true
view.colorPixelFormat = .bgra8Unorm
view.delegate = context.coordinator
return view
}
func updateUIView(_ uiView: MTKView, context: Context) {
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment