diff --git a/.gitignore b/.gitignore
index 80fc5e5..35547d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,8 @@ ignore/
.trae/
Packages/RealityKitContent/.build/
-.build/
\ No newline at end of file
+.build/
+
+buildServer.json
+
+resources/
diff --git a/notes/05-08-tile-artifacts-and-stereo-bugs.md b/notes/05-08-tile-artifacts-and-stereo-bugs.md
new file mode 100644
index 0000000..efef114
--- /dev/null
+++ b/notes/05-08-tile-artifacts-and-stereo-bugs.md
@@ -0,0 +1,132 @@
+# 05-08 瓦片伪影与立体渲染 Bug 排查记录
+
+## 症状
+
+多个 demo(PongWar、Snake3D、Stereographic 多胞体等)在 visionOS Simulator 上,
+几何体周围出现明显的彩色方块(瓦片 / tile artifacts)。同时发现过右眼黑屏的问题。
+
+---
+
+## 根本原因 1:瓦片伪影(tile artifacts)
+
+### 机制
+
+visionOS 的 foveation(注视渲染)通过 `rasterizationRateMap` 将渲染目标分成密度
+不均的 tile 块。启用 foveation 后,Metal 要求渲染器在每个 draw call 之前**显式**调
+用 `setVertexAmplificationCount`,即使 viewCount == 1 时也必须调用,否则 GPU 不
+知道如何将虚拟 viewport 坐标(如 4338×3478)映射到物理 texture(如 1888×1792),
+未覆盖的 tile 会以 clear color 的颜色暴露出来。
+
+### 直接触发条件
+
+`RendererTypes.swift` 的 `applyViewConfiguration` 的 `else` 分支被误删:
+
+```swift
+// 错误(会导致 tile artifacts):
+if viewData.viewCount > 1 {
+ encoder.setVertexAmplificationCount(...)
+}
+// ← 缺少 else,单视图时 GPU 拿不到 amplification count
+
+// 正确:
+if viewData.viewCount > 1 {
+ encoder.setVertexAmplificationCount(viewData.viewCount, viewMappings: &viewMappings)
+} else {
+ encoder.setVertexAmplificationCount(1, viewMappings: nil) // ← 必须保留!
+}
+```
+
+### 次要暴露条件:clearColor 不是纯黑
+
+Foveation tile 被 clear 到 clearColor。如果 clearColor 是纯黑 `(0,0,0,1)`,tile
+在黑色背景中不可见。如果 clearColor 有任何颜色分量,tile 就会以彩色方块出现。
+
+**当前解决方案**:Renderer.swift 中 clearColor 硬编码为纯黑,与 `main` 分支保持
+一致,完全忽略各 demo 的 `preferredClearColor` 属性。
+
+```swift
+// Renderer.swift - encodeDrawable 内
+// ⚠️ 必须是纯黑。foveation 的 rasterizationRateMap 会把渲染目标分成 tile,
+// 未被几何体覆盖的区域会被 clear 到此颜色。任何非黑色都会造成可见的彩色瓦片。
+// 各 demo 的 preferredClearColor 属性目前被忽略(见各 renderer 文件)。
+let clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+```
+
+---
+
+## 根本原因 2:右眼黑屏(两眼画面叠到左眼)
+
+### 机制
+
+visionOS layered layout 下,左右眼在**同一张 texture** 的不同 array slice(左眼
+slice=0,右眼 slice=1)。
+
+- `textureMap.textureIndex`:该 view 对应 drawable 的哪个 texture(两眼都是 0)
+- `textureMap.sliceIndex`:该 texture 内的哪个 array slice(左眼 0,右眼 1)
+
+在 `makeViewRenderingData` 中必须用 `sliceIndex`:
+
+```swift
+// 错误(导致两眼都渲染到 layer 0,右眼黑屏):
+renderTargetLayers.append(UInt32(textureMap.textureIndex))
+
+// 正确:
+renderTargetLayers.append(UInt32(textureMap.sliceIndex))
+```
+
+---
+
+## 根本原因 3:`cp_frame_end_submission` before `encodePresent` 崩溃
+
+### 机制
+
+CompositorServices 规定:调用 `queryDrawables()` 后,每个 drawable 都必须经过
+`encodePresent(commandBuffer:)` 才能调用 `endSubmission()`。
+
+当 world tracking 尚未就绪(无 deviceAnchor)时,原代码走 `completeEmptySubmission`
+路径,只调用 `startSubmission/endSubmission`,跳过了 `encodePresent`,导致崩溃。
+
+**修复**:`completeEmptySubmissionIfPossible` 接受 `drawables` 参数,对每个 drawable
+提交一个空的命令 buffer:
+
+```swift
+private func completeEmptySubmissionIfPossible(
+ for frame: LayerRenderer.Frame,
+ drawables: [LayerRenderer.Drawable] = []
+) {
+ guard layerRenderer.state == .running else { return }
+ frame.startSubmission()
+ for drawable in drawables {
+ guard let cmdBuf = commandQueue.makeCommandBuffer() else { continue }
+ drawable.encodePresent(commandBuffer: cmdBuf)
+ cmdBuf.commit()
+ }
+ frame.endSubmission()
+}
+```
+
+---
+
+## 排查过程中的误操作记录
+
+以下操作**不是解决方案**,记录以避免重蹈:
+
+1. **`isFoveationEnabled = false`**:不能消除 tile。即使禁用 foveation,
+ simulator 仍可能返回非 trivial 的 rate map。
+2. **`rasterizationRateMap = nil`**:单独设为 nil 不能解决问题,且会破坏
+ foveation 的虚拟坐标到物理坐标的映射。
+3. **用物理 texture 尺寸覆盖 viewport**:`textureMap.viewport` 是虚拟 foveation
+ 坐标,不应替换为物理尺寸,这会破坏 foveation 的工作方式。
+4. **`renderTargetArrayLength = max(min(...), 1)` 而非 `colorTexture.arrayLength`**:
+ clamp 版本会导致 render target 层数不足。
+
+---
+
+## 关键文件位置
+
+| 文件 | 关键位置 | 说明 |
+| ------------------------------ | ----------------------------------- | ---------------------------------------------------------------- |
+| `Renderer/RendererTypes.swift` | `applyViewConfiguration` | **必须**保留 else 分支调用 `setVertexAmplificationCount(1, nil)` |
+| `Renderer/Renderer.swift` | `encodeDrawable` clearColor | 硬编码黑色,不得改为读取 `preferredClearColor` |
+| `Renderer/Renderer.swift` | `makeViewRenderingData` | 用 `sliceIndex` 不是 `textureIndex` |
+| `Renderer/Renderer.swift` | `completeEmptySubmissionIfPossible` | 必须传入 drawables 并 encodePresent |
diff --git a/vr-dive.xcodeproj/project.pbxproj b/vr-dive.xcodeproj/project.pbxproj
index 8003ac0..0964a77 100644
--- a/vr-dive.xcodeproj/project.pbxproj
+++ b/vr-dive.xcodeproj/project.pbxproj
@@ -107,7 +107,7 @@
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2620;
- LastUpgradeCheck = 2620;
+ LastUpgradeCheck = 2640;
TargetAttributes = {
9A9B19C32ECF8284003DA309 = {
CreatedOnToolsVersion = 26.2;
@@ -270,6 +270,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = xros;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
XROS_DEPLOYMENT_TARGET = 26.1;
@@ -327,6 +328,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = xros;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
VALIDATE_PRODUCT = YES;
XROS_DEPLOYMENT_TARGET = 26.1;
diff --git a/vr-dive.xcodeproj/xcuserdata/chenyong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/vr-dive.xcodeproj/xcuserdata/chenyong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
new file mode 100644
index 0000000..c5bdb76
--- /dev/null
+++ b/vr-dive.xcodeproj/xcuserdata/chenyong.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
diff --git a/vr-dive/ContentView.swift b/vr-dive/ContentView.swift
index f43593e..8173133 100644
--- a/vr-dive/ContentView.swift
+++ b/vr-dive/ContentView.swift
@@ -13,14 +13,16 @@ struct ContentView: View {
@Environment(AppModel.self) private var appModel
var body: some View {
- VStack(spacing: 24) {
- PatternMenuView(model: appModel.patternMenuModel)
- ToggleImmersiveSpaceButton()
+ VStack(spacing: 28) {
+ HStack(alignment: .bottom, spacing: 20) {
+ PatternMenuView(model: appModel.patternMenuModel)
+ ToggleImmersiveSpaceButton()
+ }
ControlButtonsView(model: appModel.patternMenuModel)
}
- .padding(.horizontal, 24)
- .padding(.vertical, 28)
- .frame(maxWidth: 420)
+ .padding(.horizontal, 28)
+ .padding(.vertical, 32)
+ .frame(maxWidth: 760, minHeight: 280)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
@@ -28,18 +30,39 @@ struct ContentView: View {
struct PatternMenuView: View {
@Bindable var model: PatternMenuModel
+ private var nextPattern: VisualPatternKind {
+ let all = VisualPatternKind.allCases
+ let idx = all.firstIndex(of: model.selectedPattern) ?? 0
+ return all[(idx + 1) % all.count]
+ }
+
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("图案切换")
.font(.headline)
- Picker("当前图案", selection: $model.selectedPattern) {
- ForEach(VisualPatternKind.allCases) { pattern in
- Text(pattern.displayName).tag(pattern)
+
+ HStack(spacing: 10) {
+ Picker("当前图案", selection: $model.selectedPattern) {
+ ForEach(VisualPatternKind.allCases) { pattern in
+ Text(pattern.displayName).tag(pattern)
+ }
+ }
+ .pickerStyle(.menu)
+
+ Button(action: { model.selectedPattern = nextPattern }) {
+ VStack(spacing: 1) {
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ Text(nextPattern.displayName)
+ .font(.system(size: 9))
+ .lineLimit(1)
+ }
+ .frame(minWidth: 64)
}
+ .buttonStyle(.bordered)
}
- .pickerStyle(.menu)
}
- .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(minWidth: 360, maxWidth: .infinity, alignment: .leading)
}
}
@@ -47,31 +70,83 @@ struct ControlButtonsView: View {
@Bindable var model: PatternMenuModel
var body: some View {
- HStack(spacing: 12) {
- Button(action: {
- model.reset()
- }) {
- Label("Reset", systemImage: "arrow.counterclockwise")
+ VStack(spacing: 18) {
+ HStack(spacing: 18) {
+ Button(action: {
+ model.reset()
+ }) {
+ Label("Reset", systemImage: "arrow.counterclockwise")
+ }
+ .buttonStyle(.bordered)
+
+ Button(action: {
+ model.isPaused.toggle()
+ }) {
+ Label(
+ model.isPaused ? "Resume" : "Pause",
+ systemImage: model.isPaused ? "play.fill" : "pause.fill")
+ }
+ .buttonStyle(.bordered)
+
+ Button(action: {
+ model.toggleSpeed()
+ }) {
+ Label(
+ model.speedMultiplier > 1.0 ? "x5" : "x1",
+ systemImage: model.speedMultiplier > 1.0 ? "hare.fill" : "tortoise.fill")
+ }
+ .buttonStyle(.bordered)
}
- .buttonStyle(.bordered)
-
- Button(action: {
- model.isPaused.toggle()
- }) {
- Label(
- model.isPaused ? "Resume" : "Pause",
- systemImage: model.isPaused ? "play.fill" : "pause.fill")
+
+ if model.selectedPattern == .rayMarchingDemo {
+ Button(action: {
+ model.cycleRayMarchingProbeDimTarget()
+ }) {
+ Label(model.rayMarchingProbeDimTarget.buttonTitle, systemImage: "circle.lefthalf.filled")
+ }
+ .buttonStyle(.bordered)
+ }
+
+ if model.selectedPattern == .huashan {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("华山点比例")
+ .font(.headline)
+
+ HStack(spacing: 14) {
+ Button(action: {
+ model.adjustHuashanSampleRatio(by: -0.05)
+ }) {
+ Label("减少 5%", systemImage: "minus")
+ }
+ .buttonStyle(.bordered)
+
+ Text(model.huashanSampleRatioPercentText)
+ .font(.system(.body, design: .monospaced).weight(.semibold))
+ .frame(minWidth: 52)
+
+ Button(action: {
+ model.adjustHuashanSampleRatio(by: 0.05)
+ }) {
+ Label("增加 5%", systemImage: "plus")
+ }
+ .buttonStyle(.bordered)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
}
- .buttonStyle(.bordered)
-
- Button(action: {
- model.toggleSpeed()
- }) {
- Label(
- model.speedMultiplier > 1.0 ? "x8" : "x1",
- systemImage: model.speedMultiplier > 1.0 ? "hare.fill" : "tortoise.fill")
+
+ if model.selectedPattern.supportsOriginCellInspection {
+ Toggle(isOn: $model.originCellInspectionEnabled) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("原点胞高亮")
+ Text("自动暂停并加粗高亮包含原点的一个胞")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .toggleStyle(.switch)
+ .padding(.top, 2)
}
- .buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, alignment: .center)
}
diff --git a/vr-dive/Demos/3DFire/3DFireRenderer.swift b/vr-dive/Demos/3DFire/3DFireRenderer.swift
new file mode 100644
index 0000000..931c850
--- /dev/null
+++ b/vr-dive/Demos/3DFire/3DFireRenderer.swift
@@ -0,0 +1,177 @@
+import Metal
+import simd
+
+// 3DFireRenderer.swift
+//
+// Cube-container adaptation of ShaderToy "3XXSWS" by XorDev.
+// The visible container is a 2 m × 2 m × 2 m cube. Rays march from the
+// visible cube surface, or from the eye when the camera is inside.
+
+final class ThreeDFireRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .threeDFire
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let boxScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.5)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ThreeDFireRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0) * 1.02)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ThreeDFireRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = ThreeDFireRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ThreeDFireUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ boxScale: boxScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ThreeDFireRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for p in face.positions {
+ vertices.append(V(position: p, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "threeDFireVertex")
+ desc.fragmentFunction = library.makeFunction(name: "threeDFireFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/3DFire/3DFireShaders.metal b/vr-dive/Demos/3DFire/3DFireShaders.metal
new file mode 100644
index 0000000..27f2e77
--- /dev/null
+++ b/vr-dive/Demos/3DFire/3DFireShaders.metal
@@ -0,0 +1,134 @@
+// 3DFireShaders.metal
+// Adapted from ShaderToy "3XXSWS" by XorDev.
+// Source: https://www.shadertoy.com/view/3XXSWS
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
+//
+// Metal adaptation notes:
+// - The original shader uses a screen-space ray from a fixed synthetic camera.
+// This version uses the real per-eye world ray intersected with a 2 m cube.
+// - Outside the cube, marching starts at the visible cube surface; inside the
+// cube, marching starts at the eye.
+// - The fire volume is integrated beyond the cube entry plane, so the simulated
+// turbulence is not clipped by the container volume.
+// - GLSL `p.xz *= mat2(...)` is expanded explicitly to avoid row/column-major
+// ambiguity between GLSL and Metal matrix multiplication semantics.
+
+#include
+using namespace metal;
+
+struct ThreeDFireUniforms {
+ float time;
+ uint viewCount;
+ float boxScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ThreeDFireVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float3 FIRE_BOX_HALF = float3(1.0f);
+static constant float FIRE_EPSILON = 0.002f;
+static constant float FIRE_SCENE_SCALE = 4.0f;
+static constant int FIRE_TRACE_STEPS = 50;
+
+vertex ThreeDFireVertexOut threeDFireVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ThreeDFireUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz;
+
+ ThreeDFireVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float2 fireBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+static float2 fireTwistXZ(float2 value, float4 twistCos, float divisor) {
+ return float2(
+ value.x * twistCos.x + value.y * twistCos.y,
+ value.x * twistCos.z + value.y * twistCos.w) / divisor;
+}
+
+fragment float4 threeDFireFragment(
+ ThreeDFireVertexOut in [[stage_in]],
+ constant ThreeDFireUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float cubeScale = max(uniforms.boxScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / cubeScale;
+ float3 hit = (in.worldPos - center) / cubeScale;
+ float3 rd = normalize(hit - eye);
+
+ bool insideBox = all(abs(eye) < FIRE_BOX_HALF - 1.0e-3f);
+ float2 tBox = fireBoxIntersect(eye, rd, FIRE_BOX_HALF);
+ if (!insideBox && tBox.x > tBox.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f);
+ float3 marchOrigin = eye + rd * (tStart + FIRE_EPSILON);
+
+ float time = uniforms.time;
+ float z = 0.0f;
+ float4 color = float4(0.0f);
+
+ // Keep the original `p.z += 5. + cos(t)` motion, but center the volume in
+ // container space by offsetting the ray origin by -5 along z beforehand.
+ float3 rayOrigin = marchOrigin * FIRE_SCENE_SCALE + float3(0.0f, 0.0f, -5.0f);
+
+ for (int i = 0; i < FIRE_TRACE_STEPS; ++i) {
+ float3 p = rayOrigin + rd * z;
+ p.z += 5.0f + cos(time);
+
+ float4 twistCos = cos(p.y * 0.5f + float4(0.0f, 33.0f, 11.0f, 0.0f));
+ float divisor = max(p.y * 0.1f + 1.0f, 0.1f);
+ p.xz = fireTwistXZ(p.xz, twistCos, divisor);
+
+ for (float frequency = 2.0f; frequency < 15.0f; frequency /= 0.6f) {
+ p += cos((p.yzx - float3(time / 0.1f, time, frequency)) * frequency) / frequency;
+ }
+
+ float stepSize = 0.01f + abs(length(p.xz) + p.y * 0.3f - 0.5f) / 7.0f;
+ z += stepSize;
+
+ // In the original GLSL, `O += ... / d` happens in the loop increment,
+ // after `d` has been overwritten with the raymarch step size. Using the
+ // last turbulence frequency here makes the fire much darker than the
+ // reference, so accumulate against the actual step size instead.
+ color += (sin(z / 3.0f + float4(7.0f, 2.0f, 3.0f, 0.0f)) + 1.1f) / stepSize;
+ }
+
+ color = tanh(color / 1000.0f);
+ return float4(color.rgb, 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/3DFire/3DFireTypes.swift b/vr-dive/Demos/3DFire/3DFireTypes.swift
new file mode 100644
index 0000000..c9a44ac
--- /dev/null
+++ b/vr-dive/Demos/3DFire/3DFireTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct ThreeDFireUniforms in 3DFireShaders.metal.
+struct ThreeDFireUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var boxScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/AngleFire/AngleFireRenderer.swift b/vr-dive/Demos/AngleFire/AngleFireRenderer.swift
new file mode 100644
index 0000000..db0df67
--- /dev/null
+++ b/vr-dive/Demos/AngleFire/AngleFireRenderer.swift
@@ -0,0 +1,168 @@
+import Metal
+import simd
+
+// AngleFireRenderer.swift
+//
+// Source reference:
+// https://www.shadertoy.com/view/3XXSDB
+// "Angel" by @XorDev — an experiment based on "3D Fire":
+// https://www.shadertoy.com/view/3XXSWS
+// License: see original ShaderToy page
+
+final class AngleFireRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .angleFire
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // 2 metre cube (half-extent = 1 m)
+ private let cubeScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.75)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = AngleFireRenderer.makeBox(device: device)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try AngleFireRenderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = AngleFireRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.back)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = AngleFireUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension AngleFireRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let x: Float = 1.0
+ let y: Float = 1.0
+ let z: Float = 1.0
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vBuf = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let iBuf = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vBuf, iBuf, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice, library: MTLLibrary, maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "angleFireVertex")
+ desc.fragmentFunction = library.makeFunction(name: "angleFireFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vd = MTLVertexDescriptor()
+ vd.attributes[0].format = .float3
+ vd.attributes[0].offset = 0
+ vd.attributes[0].bufferIndex = 0
+ vd.attributes[1].format = .float3
+ vd.attributes[1].offset = MemoryLayout>.stride
+ vd.attributes[1].bufferIndex = 0
+ vd.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vd
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/AngleFire/AngleFireShaders.metal b/vr-dive/Demos/AngleFire/AngleFireShaders.metal
new file mode 100644
index 0000000..c9e7007
--- /dev/null
+++ b/vr-dive/Demos/AngleFire/AngleFireShaders.metal
@@ -0,0 +1,155 @@
+// AngleFireShaders.metal
+//
+// Source reference:
+// https://www.shadertoy.com/view/3XXSDB
+// "Angel" by @XorDev — an experiment based on "3D Fire":
+// https://www.shadertoy.com/view/3XXSWS
+// License: see original ShaderToy page
+//
+// Adapted for vr-dive: renders inside a view-independent 2 metre cube container.
+// The original fixed ShaderToy camera is replaced by visionOS head-pose ray marching.
+// A box intersection constrains the volumetric march; the fire column (cylinder SDF
+// along the Y axis) is centred at the box origin and visible from all directions.
+
+#include
+using namespace metal;
+
+// ── Tuning ────────────────────────────────────────────────────────────────────
+// Maps local box coords [-1,1] → scene units. Cylinder radius is 0.5 in scene
+// units; turbulence displaces ~±5 units. Scale 4 gives the best view density.
+#define AF_SCENE_SCALE 4.0f
+#define AF_STEPS 60 // reduced from 100 for performance
+#define AF_MAX_T 30.0f // max march distance cap in local box units
+
+// ── Structs ───────────────────────────────────────────────────────────────────
+struct AngleFireUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct AngleFireVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+// ── Vertex shader ─────────────────────────────────────────────────────────────
+vertex AngleFireVertexOut angleFireVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant AngleFireUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ AngleFireVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+// ── Box intersection (slab method; handles inside-box camera) ─────────────────
+static bool af_boxHit(
+ float3 ro, float3 rd, float3 bmin, float3 bmax,
+ thread float &tNear, thread float &tFar)
+{
+ float3 t0 = (bmin - ro) / rd;
+ float3 t1 = (bmax - ro) / rd;
+ float3 lo = min(t0, t1);
+ float3 hi = max(t0, t1);
+ tNear = max(max(lo.x, lo.y), lo.z);
+ tFar = min(min(hi.x, hi.y), hi.z);
+ return tFar >= max(tNear, 0.0f);
+}
+
+// ── Fragment shader ───────────────────────────────────────────────────────────
+fragment float4 angleFireFragment(
+ AngleFireVertexOut in [[stage_in]],
+ constant AngleFireUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *v2wMats [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = v2wMats[vi];
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+
+ float3 center = uniforms.objectCenter.xyz;
+ float halfSize = uniforms.cubeScale;
+
+ // Ray in local cube space [-1, 1]^3
+ float3 roLocal = (camWorld - center) / halfSize;
+ float3 rdLocal = normalize(in.worldPos - camWorld);
+
+ float tNear, tFar;
+ if (!af_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tNear, tFar)) {
+ discard_fragment();
+ }
+
+ // Map to scene space. The fire column is a cylinder of radius 0.5 along Y,
+ // centred at the scene origin — visible from all viewing angles.
+ float3 ro = roLocal * AF_SCENE_SCALE;
+ float3 rd = normalize(rdLocal);
+
+ float iTime = uniforms.time * 0.2f; // slow to 1/5 of accumulated time
+
+ // ── Volumetric ray march — port of mainImage() from ShaderToy 3XXSDB ─────
+ //
+ // Each outer step:
+ // 1. Twist p.xz with a y-dependent non-orthogonal rotation (the "angel" shape)
+ // 2. Add 10-octave turbulence driven by time
+ // 3. Evaluate distorted cylinder SDF as the march step size
+ // 4. Accumulate additive glow: brightness ∝ 1/SDF, colour cycles with depth z
+ //
+ // The original zeros O with `O *= i` (i=0 at start); here we just init to 0.
+
+ float4 O = float4(0.0f);
+ float z = max(tNear, 0.0f) * AF_SCENE_SCALE; // scene-space march depth
+ float zEnd = min(tFar, AF_MAX_T) * AF_SCENE_SCALE;
+ float d;
+
+ for (int i = 0; i < AF_STEPS && z < zEnd; i++) {
+ // Scale scene coordinates ×4 so the flame content appears 1/4 the size
+ // (two successive halvings) while the outer box container is unchanged.
+ float3 p = (ro + rd * z) * 4.0f;
+
+ // Twist shape: y-dependent rotation in xz plane.
+ // Precompute the 3 unique cos values to avoid redundant evaluations.
+ float py = p.y * 0.5f;
+ float c0 = cos(py);
+ float c33 = cos(py + 33.0f);
+ float c11 = cos(py + 11.0f);
+ float2x2 twistMat = float2x2(
+ float2(c0, c33), // col0
+ float2(c11, c0)); // col1
+ p.xz = p.xz * twistMat;
+
+ // Turbulence distortion loop — reduced to ~7 octaves (d: 1→3.8 via ×1.25).
+ // Original used 10 octaves (d<9); reducing to d<4 cuts ~30% of GPU cost
+ // with minimal visual difference since high-frequency octaves contribute little.
+ float3 tdir = float3(3.0f, 1.0f, 0.0f);
+ for (d = 1.0f; d < 4.0f; d /= 0.8f)
+ p += cos((p.yzx - iTime * tdir) * d) / d;
+
+ // Distorted cylinder SDF (radius 0.5) as the step size.
+ // Small d near the cylinder → many samples → bright glow.
+ z += d = (0.1f + abs(length(p.xz) - 0.5f)) / 20.0f;
+
+ // Additive glow: depth-based rainbow colour, amplitude ∝ 1/SDF.
+ O += (sin(z + float4(2.0f, 3.0f, 4.0f, 0.0f)) + 1.1f) / d;
+ }
+
+ // Tanh tone mapping — matches original `tanh(O / 4e3)`.
+ float3 col = tanh(O.rgb / 4000.0f);
+ return float4(col, 1.0f);
+}
diff --git a/vr-dive/Demos/AngleFire/AngleFireTypes.swift b/vr-dive/Demos/AngleFire/AngleFireTypes.swift
new file mode 100644
index 0000000..41e0834
--- /dev/null
+++ b/vr-dive/Demos/AngleFire/AngleFireTypes.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct AngleFireUniforms in
+/// AngleFireShaders.metal.
+struct AngleFireUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/AnotherMarble/AnotherMarbleRenderer.swift b/vr-dive/Demos/AnotherMarble/AnotherMarbleRenderer.swift
new file mode 100644
index 0000000..0ebfda7
--- /dev/null
+++ b/vr-dive/Demos/AnotherMarble/AnotherMarbleRenderer.swift
@@ -0,0 +1,174 @@
+import Metal
+import simd
+
+// AnotherMarbleRenderer.swift
+// Cube-container adaptation of ShaderToy "Another Marble" (lsG3D3).
+
+final class AnotherMarbleRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .anotherMarble
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let cubeScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.8)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = AnotherMarbleRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0) * 1.01)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try AnotherMarbleRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = AnotherMarbleRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0) * 0.45
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = AnotherMarbleUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension AnotherMarbleRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "anotherMarbleVertex")
+ desc.fragmentFunction = library.makeFunction(name: "anotherMarbleFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/AnotherMarble/AnotherMarbleShaders.metal b/vr-dive/Demos/AnotherMarble/AnotherMarbleShaders.metal
new file mode 100644
index 0000000..9af8078
--- /dev/null
+++ b/vr-dive/Demos/AnotherMarble/AnotherMarbleShaders.metal
@@ -0,0 +1,216 @@
+// AnotherMarbleShaders.metal
+// "Another Marble" — cube-container adaptation of ShaderToy lsG3D3.
+// Source: https://www.shadertoy.com/view/lsG3D3
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
+//
+// Adaptation notes:
+// - The original GLSL uses a synthetic screen camera orbiting a glass sphere and
+// samples an environment cubemap via iChannel0.
+// - This version reconstructs a real per-eye ray from the visible 2 m cube,
+// begins marching from the cube surface when outside or from the eye when
+// inside, and replaces the cubemap dependency with a procedural environment.
+// - The marble volume is evaluated in scene space and is not clipped by the cube.
+
+#include
+using namespace metal;
+
+struct AnotherMarbleUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct AnotherMarbleVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float AM_ZOOM = 1.25f;
+static constant float AM_SIZE = 0.19f;
+static constant float AM_SPHERE_RADIUS = 2.0f;
+static constant float3 AM_BOX_HALF = float3(1.0f);
+
+vertex AnotherMarbleVertexOut anotherMarbleVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant AnotherMarbleUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ AnotherMarbleVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float2 amCSqr(float2 a) {
+ return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y);
+}
+
+static float2 amRot(float2 p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ return float2(c * p.x + s * p.y, -s * p.x + c * p.y);
+}
+
+static float3 amACESFilm(float3 x) {
+ const float a = 2.51f;
+ const float b = 0.03f;
+ const float c = 2.43f;
+ const float d = 0.59f;
+ const float e = 0.14f;
+ return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0f, 1.0f);
+}
+
+static float2 amSphereIntersect(float3 ro, float3 rd, float4 sph) {
+ float3 oc = ro - sph.xyz;
+ float b = dot(oc, rd);
+ float c = dot(oc, oc) - sph.w * sph.w;
+ float h = b * b - c;
+ if (h < 0.0f) {
+ return float2(-1.0f);
+ }
+ h = sqrt(h);
+ return float2(-b - h, -b + h);
+}
+
+static float amMap(float3 p, float time) {
+ float res = 0.0f;
+ float st = cos(time * 0.1f) * 0.4f;
+ float3 c = p;
+ for (int i = 0; i < 6; ++i) {
+ p = 0.4f * abs(p) / max(dot(p, p), 1.0e-4f) - 0.3f + st;
+ p.yz = amCSqr(p.yz);
+ res += exp(-20.0f * abs(dot(p, c)));
+ }
+ return res * 0.325f;
+}
+
+static float3 amEnvironment(float3 dir, float time) {
+ dir = normalize(dir);
+ float skyMix = clamp(dir.y * 0.5f + 0.5f, 0.0f, 1.0f);
+ float horizon = pow(max(1.0f - abs(dir.y), 0.0f), 4.0f);
+ float sun = pow(max(dot(dir, normalize(float3(0.32f, 0.44f, -0.84f))), 0.0f), 64.0f);
+ float shimmer = 0.5f + 0.5f * sin((dir.x - dir.z) * 12.0f + time * 0.2f);
+ float3 sky = mix(float3(0.015f, 0.02f, 0.04f), float3(0.18f, 0.26f, 0.34f), skyMix);
+ sky += horizon * float3(0.20f, 0.18f, 0.16f) * 0.35f * shimmer;
+ sky += float3(1.0f, 0.95f, 0.88f) * sun;
+ return clamp(sky, 0.0f, 2.0f);
+}
+
+static float2 amBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+static float2 amFaceUV(float3 p) {
+ float3 ap = abs(p);
+ float2 uv;
+ if (ap.x >= ap.y && ap.x >= ap.z) {
+ uv = p.zy;
+ } else if (ap.y >= ap.z) {
+ uv = p.xz;
+ } else {
+ uv = p.xy;
+ }
+ return clamp(uv * 0.5f + 0.5f, 0.0f, 1.0f);
+}
+
+static float3 amRaymarch(float3 ro, float3 rd, float2 tminmax, float time) {
+ float t = tminmax.x;
+ float m = cos(time * 0.1f) - 5.0f;
+ float safeTMin = max(tminmax.x, 0.02f);
+ float dt = (tminmax.y / safeTMin) * 0.25f;
+ float3 col = float3(0.0f);
+ float c = 0.0f;
+
+ for (int i = 0; i < 192; ++i) {
+ t += dt * exp(m * c);
+ if (t > tminmax.y) {
+ break;
+ }
+ float3 pos = (ro + t * rd) * AM_SIZE;
+ c = amMap(pos, time);
+ col += float3(
+ c * (c + 0.5f) * c * c - pos.z,
+ c * c * c - pos.y,
+ c * c - pos.x) + rd * rd * c * c;
+ }
+ return col * 0.003f;
+}
+
+fragment float4 anotherMarbleFragment(
+ AnotherMarbleVertexOut in [[stage_in]],
+ constant AnotherMarbleUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float scale = max(uniforms.cubeScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / scale;
+ float3 surfacePos = (in.worldPos - center) / scale;
+ float3 rd = normalize(surfacePos - eye);
+
+ bool insideOuter = all(abs(eye) < AM_BOX_HALF - 1.0e-3f);
+ float2 tOuter = amBoxIntersect(eye, rd, AM_BOX_HALF);
+ if (!insideOuter && tOuter.x > tOuter.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f);
+ float3 ro = (eye + rd * (tStart + 0.001f)) * (AM_ZOOM * 3.2f);
+
+ float orbit = -0.1f * uniforms.time;
+ ro.yz = amRot(ro.yz, 0.18f * sin(uniforms.time * 0.07f));
+ ro.xz = amRot(ro.xz, orbit);
+ float3 marchDir = normalize(rd);
+ marchDir.yz = amRot(marchDir.yz, 0.18f * sin(uniforms.time * 0.07f));
+ marchDir.xz = amRot(marchDir.xz, orbit);
+
+ float2 tmm = amSphereIntersect(ro, marchDir, float4(0.0f, 0.0f, 0.0f, AM_SPHERE_RADIUS));
+ float3 col;
+ if (tmm.x < 0.0f && tmm.y < 0.0f) {
+ col = amEnvironment(marchDir, uniforms.time) * 2.0f;
+ } else {
+ float tNear = max(tmm.x * 0.6f, 0.0f);
+ float tFar = max(tmm.y, tNear);
+ col = amRaymarch(ro, marchDir, float2(tNear, tFar), uniforms.time);
+
+ float tSurface = (tmm.x > 0.0f) ? tmm.x : tmm.y;
+ float3 surfaceNormal = (ro + tSurface * marchDir) / AM_SPHERE_RADIUS;
+ float3 reflected = reflect(marchDir, surfaceNormal);
+ float fre = pow(0.5f + clamp(dot(reflected, marchDir), 0.0f, 1.0f), 3.0f) * 1.2f;
+ col += amEnvironment(reflected, uniforms.time) * fre;
+ }
+
+ col = amACESFilm(col);
+ col *= col;
+ col *= col;
+
+ float2 faceUV = amFaceUV(surfacePos) * 2.0f - 1.0f;
+ float vignette = 1.0f - 0.18f * dot(faceUV, faceUV);
+ col *= vignette;
+ return float4(clamp(col, 0.0f, 1.0f), 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/AnotherMarble/AnotherMarbleTypes.swift b/vr-dive/Demos/AnotherMarble/AnotherMarbleTypes.swift
new file mode 100644
index 0000000..1dad46f
--- /dev/null
+++ b/vr-dive/Demos/AnotherMarble/AnotherMarbleTypes.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct AnotherMarbleUniforms in
+/// AnotherMarbleShaders.metal.
+struct AnotherMarbleUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/ApolloSpiral/ApolloSpiralRenderer.swift b/vr-dive/Demos/ApolloSpiral/ApolloSpiralRenderer.swift
new file mode 100644
index 0000000..2bec55b
--- /dev/null
+++ b/vr-dive/Demos/ApolloSpiral/ApolloSpiralRenderer.swift
@@ -0,0 +1,173 @@
+import Metal
+import simd
+
+// ApolloSpiralRenderer.swift
+// Source adaptation: Shadertoy "Apollo Spiral"
+// https://www.shadertoy.com/view/WXVGRG
+//
+// The original shader is a screen-space raymarch. This version renders the
+// adapted field inside a 1 m cube container so it can be viewed from any
+// direction in immersive space.
+
+final class ApolloSpiralRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .apolloSpiral
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let cubeScale: Float = 0.5
+ private let objectCenter = SIMD3(0.0, -0.02, -1.6)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ApolloSpiralRenderer.makeBox(
+ device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ApolloSpiralRenderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = ApolloSpiralRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ApolloSpiralUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ApolloSpiralRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared
+ )!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared
+ )!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "apolloSpiralVertex")
+ desc.fragmentFunction = library.makeFunction(name: "apolloSpiralFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/ApolloSpiral/ApolloSpiralShaders.metal b/vr-dive/Demos/ApolloSpiral/ApolloSpiralShaders.metal
new file mode 100644
index 0000000..8ee5075
--- /dev/null
+++ b/vr-dive/Demos/ApolloSpiral/ApolloSpiralShaders.metal
@@ -0,0 +1,196 @@
+// ApolloSpiralShaders.metal
+// Source adaptation: Shadertoy "Apollo Spiral"
+// https://www.shadertoy.com/view/WXVGRG
+//
+// The original shader is a screen-space raymarch / accumulation effect.
+// This version adapts the core fractal and spiral field into a 3D cube-contained
+// volume so the result can be observed from any direction in immersive space.
+// The cube acts as a portal when viewed from outside so deeper structure can
+// remain visible instead of being clipped at the back face.
+
+#include
+using namespace metal;
+
+struct ApolloSpiralUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ApolloSpiralVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+vertex ApolloSpiralVertexOut apolloSpiralVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ApolloSpiralUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ ApolloSpiralVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static constant float3 AP_BOX_HALF = float3(1.0f, 1.0f, 1.0f);
+static constant int AP_MAX_VOLUME_STEPS = 480;
+static constant float AP_MIN_STEP = 0.007f;
+static constant float AP_MAX_STEP = 0.045f;
+static constant float AP_PATTERN_SCALE = 2.15f;
+static constant float AP_OUTSIDE_VIEW_DEPTH = 10.0f;
+
+static float2 apRot2d(float2 p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ return float2(c * p.x - s * p.y, s * p.x + c * p.y);
+}
+
+// Adapted from the Shadertoy source fractal() function.
+static float apFractal(float3 p) {
+ float w = 4.0f;
+ for (int iter = 0; iter < 6; ++iter) {
+ p = cos(p - 0.5f);
+ float l = 2.0f / max(dot(p, p), 0.18f);
+ p *= l;
+ w *= l;
+ }
+ return length(p) / w;
+}
+
+// Adapted from the first Apollo Spiral volume attempt. Keep the original
+// volume-field logic and only tune the scale / accumulation parameters.
+static float apSpiralField(float3 p, float time) {
+ float3 q = p * AP_PATTERN_SCALE;
+ q.z += time * 2.0f;
+ q.xy = apRot2d(q.xy, 0.05f * time + q.z * 0.2f);
+
+ float s = sin(4.0f + q.y + q.x);
+ for (float n = 5.0f; n < 16.0f; n += n) {
+ s -= abs(dot(cos(q * n), float3(1.0f))) / n;
+ }
+ return abs(min(apFractal(q), s));
+}
+
+static float3 apPalette(float glow, float3 p) {
+ float3 c = pow(float3(glow), float3(1.0f, 2.0f, 12.0f)) * 6.0f;
+ c = tanh(mix(c, c.yzx, clamp(length(p.xy) * 0.8f, 0.0f, 1.0f)));
+ return c;
+}
+
+static float apBoxHit(float3 ro, float3 rd, float3 halfExtents, thread float3 &nn, bool entering) {
+ rd += 0.0001f * (1.0f - abs(sign(rd)));
+ float3 dr = 1.0f / rd;
+ float3 n = ro * dr;
+ float3 k = halfExtents * abs(dr);
+ float3 pin = -k - n;
+ float3 pout = k - n;
+ float tin = max(pin.x, max(pin.y, pin.z));
+ float tout = min(pout.x, min(pout.y, pout.z));
+ if (tin > tout) {
+ return -1.0f;
+ }
+ if (entering) {
+ nn = -sign(rd) * step(pin.zxy, pin.xyz) * step(pin.yzx, pin.xyz);
+ return tin;
+ }
+ nn = sign(rd) * step(pout.xyz, pout.zxy) * step(pout.xyz, pout.yzx);
+ return tout;
+}
+
+fragment float4 apolloSpiralFragment(
+ ApolloSpiralVertexOut in [[stage_in]],
+ constant ApolloSpiralUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+
+ float3 center = uniforms.objectCenter.xyz;
+ float scale = uniforms.cubeScale;
+ float3 eye = (camWorld - center) / scale;
+ float3 rdWorld = normalize(in.worldPos - camWorld);
+ float3 rd = rdWorld / max(scale, 1e-4f);
+ float3 rdUnit = normalize(rd);
+
+ bool insideBox = all(abs(eye) < (AP_BOX_HALF - 1e-3f));
+ float3 entryNormal;
+ float entryT = apBoxHit(eye, rd, AP_BOX_HALF, entryNormal, !insideBox);
+ if (entryT < 0.0f) {
+ discard_fragment();
+ }
+
+ float3 entryPoint = eye + rd * entryT;
+ float3 faceNormal = insideBox ? -entryNormal : entryNormal;
+ float2 faceCoords = entryPoint.xy * faceNormal.z / AP_BOX_HALF.xy
+ + entryPoint.yz * faceNormal.x / AP_BOX_HALF.yz
+ + entryPoint.zx * faceNormal.y / AP_BOX_HALF.zx;
+ float edgeCoord = max(abs(faceCoords.x), abs(faceCoords.y));
+ float edgeGlow = smoothstep(0.84f, 0.985f, edgeCoord);
+ float faceFade = 1.0f - smoothstep(0.92f, 1.02f, edgeCoord);
+
+ float3 marchOrigin = insideBox ? (eye + rd * 0.0015f) : (entryPoint + rd * 0.0015f);
+ float exitT;
+ if (insideBox) {
+ exitT = max(entryT - 0.0015f, 0.0f);
+ } else {
+ float3 exitNormal;
+ float throughCube = apBoxHit(marchOrigin, rd, AP_BOX_HALF, exitNormal, false);
+ if (throughCube < 0.0f) {
+ throughCube = 4.0f;
+ }
+ exitT = throughCube + AP_OUTSIDE_VIEW_DEPTH;
+ }
+
+ float3 glassBase = mix(float3(0.012f, 0.015f, 0.025f), float3(0.05f, 0.09f, 0.16f), 1.0f - faceFade);
+ glassBase += edgeGlow * float3(0.10f, 0.18f, 0.30f);
+
+ float t = 0.0f;
+ float transmittance = 1.0f;
+ float3 accum = float3(0.0f);
+
+ for (int i = 0; i < AP_MAX_VOLUME_STEPS; ++i) {
+ if (t >= exitT || transmittance < 0.00035f) {
+ break;
+ }
+
+ float3 pos = marchOrigin + rd * t;
+ float field = apSpiralField(pos, uniforms.time);
+ float density = exp(-14.0f * field);
+ density *= smoothstep(26.0f, 0.15f, length(pos));
+
+ float glow = 1.0f / (0.045f + field * 10.0f);
+ float3 sampleCol = apPalette(glow * 0.060f, pos);
+ sampleCol *= mix(float3(0.9f, 0.55f, 0.35f), float3(0.45f, 0.85f, 1.25f), clamp(length(pos.xy) * 0.42f + 0.15f, 0.0f, 1.0f));
+
+ float alpha = clamp(density * 0.050f, 0.0f, 0.08f);
+ accum += transmittance * sampleCol * alpha;
+ transmittance *= (1.0f - alpha);
+
+ float stepSize = clamp(0.010f + field * 0.10f, AP_MIN_STEP, AP_MAX_STEP);
+ t += stepSize;
+ }
+
+ float3 col = accum + transmittance * glassBase;
+ float fresnel = pow(clamp(1.0f - abs(dot(faceNormal, rdUnit)), 0.0f, 1.0f), 3.2f);
+ col += fresnel * float3(0.06f, 0.10f, 0.16f);
+ col = mix(col, glassBase + edgeGlow * float3(0.15f, 0.22f, 0.34f), 0.10f);
+ col = clamp(tanh(col), 0.0f, 1.0f);
+ return float4(col, 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/ApolloSpiral/ApolloSpiralTypes.swift b/vr-dive/Demos/ApolloSpiral/ApolloSpiralTypes.swift
new file mode 100644
index 0000000..77ddc73
--- /dev/null
+++ b/vr-dive/Demos/ApolloSpiral/ApolloSpiralTypes.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct ApolloSpiralUniforms in
+/// ApolloSpiralShaders.metal.
+struct ApolloSpiralUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/Apollonian/ApollonianRenderer.swift b/vr-dive/Demos/Apollonian/ApollonianRenderer.swift
new file mode 100644
index 0000000..026a83c
--- /dev/null
+++ b/vr-dive/Demos/Apollonian/ApollonianRenderer.swift
@@ -0,0 +1,169 @@
+import Metal
+import simd
+
+// ApollonianRenderer.swift
+// "apollonian" — cube-portal adaptation of Shadertoy "3l2czd"
+// Original: https://www.shadertoy.com/view/3l2czd
+
+final class ApollonianRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .apollonian
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // 2 m cube: mesh half-extents 1.0 × cubeScale 1.0 = 1 m half-extents in world space.
+ private let cubeScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -2.1)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ApollonianRenderer.makeBox(
+ device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ApollonianRenderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = ApollonianRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ApollonianUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ApollonianRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared
+ )!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared
+ )!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "apollonianVertex")
+ desc.fragmentFunction = library.makeFunction(name: "apollonianFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/Apollonian/ApollonianShaders.metal b/vr-dive/Demos/Apollonian/ApollonianShaders.metal
new file mode 100644
index 0000000..868eca3
--- /dev/null
+++ b/vr-dive/Demos/Apollonian/ApollonianShaders.metal
@@ -0,0 +1,235 @@
+// ApollonianShaders.metal
+// "apollonian" — cube-portal adaptation of Shadertoy "3l2czd"
+// Original: https://www.shadertoy.com/view/3l2czd
+// Original authorship note in source: created by inigo quilez - iq/2013,
+// modified by jorge2017a1. License: CC BY-NC-SA 3.0.
+//
+// Metal adaptation notes:
+// - The original mainImage/mainVR shader uses a synthetic camera. This version
+// uses the real per-eye world ray from the 2 m cube container.
+// - Outside the cube, marching starts at the visible cube surface; inside the
+// cube, marching starts at the eye.
+// - The Apollonian field extends beyond the cube and is not clipped by the
+// container's back face.
+
+#include
+using namespace metal;
+
+struct ApollonianUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ApollonianVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+struct ApollonianMapData {
+ float dist;
+ float4 orb;
+};
+
+static constant float AP_MAX_DISTANCE = 30.0f;
+static constant int AP_MAX_TRACE_STEPS = 200;
+static constant float AP_SCENE_SCALE = 2.7f;
+static constant float3 AP_SCENE_OFFSET = float3(1.64f, 2.4f, -0.6f);
+static constant float3 AP_BOX_HALF = float3(1.0f);
+
+vertex ApollonianVertexOut apollonianVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ApollonianUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ ApollonianVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float apIntersectSDF(float distA, float distB) {
+ return max(distA, distB);
+}
+
+static float apSdSphere(float3 p, float s) {
+ return length(p) - s;
+}
+
+static float apBoxHit(float3 ro, float3 rd, float3 halfExtents, thread float3 &nn, bool entering) {
+ rd += 0.0001f * (1.0f - abs(sign(rd)));
+ float3 dr = 1.0f / rd;
+ float3 n = ro * dr;
+ float3 k = halfExtents * abs(dr);
+ float3 pin = -k - n;
+ float3 pout = k - n;
+ float tin = max(pin.x, max(pin.y, pin.z));
+ float tout = min(pout.x, min(pout.y, pout.z));
+ if (tin > tout) {
+ return -1.0f;
+ }
+ if (entering) {
+ nn = -sign(rd) * step(pin.zxy, pin.xyz) * step(pin.yzx, pin.xyz);
+ return tin;
+ }
+ nn = sign(rd) * step(pout.xyz, pout.zxy) * step(pout.xyz, pout.yzx);
+ return tout;
+}
+
+static ApollonianMapData apMap1(float3 p) {
+ float scale = 1.0f;
+ float4 orb = float4(1000.0f);
+
+ for (int i = 0; i < 8; ++i) {
+ p = -1.0f + 2.0f * fract(0.5f * p + 0.5f);
+
+ float r2 = dot(p, p);
+ orb = min(orb, float4(abs(p), r2));
+
+ float k = 1.75f / r2;
+ p *= k;
+ scale *= k;
+ }
+
+ ApollonianMapData result;
+ result.dist = 0.25f * abs(p.y) / scale;
+ result.orb = orb;
+ return result;
+}
+
+static ApollonianMapData apMap(float3 p) {
+ ApollonianMapData fractal = apMap1(p);
+ float sphere = apSdSphere(p, 2.0f);
+
+ ApollonianMapData result;
+ result.dist = apIntersectSDF(sphere, fractal.dist);
+ result.orb = fractal.orb;
+ return result;
+}
+
+static float apMapDistance(float3 p) {
+ return apMap(p).dist;
+}
+
+static float apTrace(float3 ro, float3 rd, thread float4 &orbOut) {
+ float t = 0.01f;
+ orbOut = float4(1000.0f);
+
+ for (int i = 0; i < AP_MAX_TRACE_STEPS; ++i) {
+ float precis = 0.001f * t;
+ ApollonianMapData hit = apMap(ro + rd * t);
+ orbOut = hit.orb;
+ if (hit.dist < precis || t > AP_MAX_DISTANCE) {
+ break;
+ }
+ t += hit.dist;
+ }
+
+ if (t > AP_MAX_DISTANCE) {
+ return -1.0f;
+ }
+ return t;
+}
+
+static float3 apCalcNormal(float3 pos, float t) {
+ float precis = 0.001f * t;
+ float2 e = float2(1.0f, -1.0f) * precis;
+ return normalize(
+ e.xyy * apMapDistance(pos + e.xyy) +
+ e.yyx * apMapDistance(pos + e.yyx) +
+ e.yxy * apMapDistance(pos + e.yxy) +
+ e.xxx * apMapDistance(pos + e.xxx));
+}
+
+static float3 apRender(float3 ro, float3 rd, float anim) {
+ float4 trap;
+ float t = apTrace(ro, rd, trap);
+ if (t <= 0.0f) {
+ float horizon = pow(1.0f - abs(rd.y), 3.0f);
+ float3 bg = mix(float3(0.005f, 0.007f, 0.012f), float3(0.02f, 0.03f, 0.06f), rd.y * 0.5f + 0.5f);
+ return bg + horizon * float3(0.02f, 0.03f, 0.05f);
+ }
+
+ float3 pos = ro + t * rd;
+ float3 nor = apCalcNormal(pos, t);
+
+ float3 light1 = normalize(float3(0.577f, 0.577f, -0.577f));
+ float3 light2 = normalize(float3(-0.707f, 0.0f, 0.707f));
+ float key = clamp(dot(light1, nor), 0.0f, 1.0f);
+ float bac = clamp(0.2f + 0.8f * dot(light2, nor), 0.0f, 1.0f);
+ float amb = 0.7f + 0.3f * nor.y;
+ float ao = pow(clamp(trap.w * 2.0f, 0.0f, 1.0f), 1.2f);
+
+ float3 brdf = float3(0.40f) * amb * ao;
+ brdf += float3(1.0f) * key * ao;
+ brdf += float3(0.40f) * bac * ao;
+
+ float3 rgb = float3(1.0f);
+ rgb = mix(rgb, float3(1.0f, 0.80f, 0.2f), clamp(6.0f * trap.y, 0.0f, 1.0f));
+ rgb = mix(rgb, float3(1.0f, 0.55f, 0.0f), pow(clamp(1.0f - 2.0f * trap.z, 0.0f, 1.0f), 8.0f));
+
+ float3 col = rgb * brdf * exp(-0.2f * t);
+ col += 0.06f * anim * pow(max(1.0f + dot(rd, nor), 0.0f), 4.0f) * float3(1.0f, 0.7f, 0.25f);
+ return sqrt(max(col, 0.0f));
+}
+
+fragment float4 apollonianFragment(
+ ApollonianVertexOut in [[stage_in]],
+ constant ApollonianUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float sceneScale = max(uniforms.cubeScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / sceneScale;
+ float3 rd = normalize(in.worldPos - camWorld);
+
+ bool insideBox = all(abs(eye) < float3(0.999f));
+ float3 faceNormal;
+ float entryT = insideBox ? 0.0f : apBoxHit(eye, rd, AP_BOX_HALF, faceNormal, true);
+ if (!insideBox && entryT < 0.0f) {
+ discard_fragment();
+ }
+
+ float3 marchOrigin = insideBox ? (eye + rd * 0.002f) : (eye + rd * (entryT + 0.002f));
+
+ float time = uniforms.time * 0.25f;
+ float anim = 1.1f + 0.5f * smoothstep(-0.3f, 0.3f, cos(0.4f * time));
+
+ float3 roScene = marchOrigin * AP_SCENE_SCALE + AP_SCENE_OFFSET;
+ float3 color = apRender(roScene, rd, anim);
+
+ float3 surfacePos = insideBox ? eye : (eye + rd * entryT);
+ float3 surfaceNormal = float3(0.0f, 0.0f, 1.0f);
+ float3 absSurface = abs(surfacePos);
+ if (absSurface.x > absSurface.y && absSurface.x > absSurface.z) {
+ surfaceNormal = float3(sign(surfacePos.x), 0.0f, 0.0f);
+ } else if (absSurface.y > absSurface.z) {
+ surfaceNormal = float3(0.0f, sign(surfacePos.y), 0.0f);
+ } else {
+ surfaceNormal = float3(0.0f, 0.0f, sign(surfacePos.z));
+ }
+
+ float fresnel = pow(1.0f - max(dot(-rd, surfaceNormal), 0.0f), 2.2f);
+ color += float3(0.04f, 0.08f, 0.14f) * fresnel * 0.08f;
+
+ return float4(clamp(color, 0.0f, 1.0f), 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/Apollonian/ApollonianTypes.swift b/vr-dive/Demos/Apollonian/ApollonianTypes.swift
new file mode 100644
index 0000000..6159be1
--- /dev/null
+++ b/vr-dive/Demos/Apollonian/ApollonianTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct ApollonianUniforms in ApollonianShaders.metal.
+struct ApollonianUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/ApollonianElevator/ApollonianElevatorRenderer.swift b/vr-dive/Demos/ApollonianElevator/ApollonianElevatorRenderer.swift
new file mode 100644
index 0000000..6ae70da
--- /dev/null
+++ b/vr-dive/Demos/ApollonianElevator/ApollonianElevatorRenderer.swift
@@ -0,0 +1,177 @@
+import Metal
+import simd
+
+// ApollonianElevatorRenderer.swift
+//
+// Cube-container adaptation of ShaderToy "Xtlyzl" by coyote.
+// The visible container is a 2 m × 2 m × 2 m cube. Rays march from the
+// entry point on the cube surface, or from the eye when the camera is inside.
+
+final class ApollonianElevatorRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .apollonianElevator
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let boxScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.5)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ApollonianElevatorRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0) * 1.02)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ApollonianElevatorRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = ApollonianElevatorRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ApollonianElevatorUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ boxScale: boxScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ApollonianElevatorRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for p in face.positions {
+ vertices.append(V(position: p, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "apollonianElevatorVertex")
+ desc.fragmentFunction = library.makeFunction(name: "apollonianElevatorFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/ApollonianElevator/ApollonianElevatorShaders.metal b/vr-dive/Demos/ApollonianElevator/ApollonianElevatorShaders.metal
new file mode 100644
index 0000000..6ecb5b6
--- /dev/null
+++ b/vr-dive/Demos/ApollonianElevator/ApollonianElevatorShaders.metal
@@ -0,0 +1,168 @@
+// ApollonianElevatorShaders.metal
+// Adapted from ShaderToy "Xtlyzl" by coyote.
+// Source: https://www.shadertoy.com/view/Xtlyzl
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
+//
+// Metal adaptation notes:
+// - The original shader uses an implicit screen-space camera and a compact macro.
+// - This version uses the real per-eye world ray intersected with a 2 m cube.
+// - Marching starts at the visible cube surface, or at the eye when the camera
+// is inside the cube.
+// - GLSL `mod(p - 1., 2.) - 1.` is expanded explicitly with floor-based modulo,
+// since Metal's `fmod` does not match GLSL's negative-input behavior.
+
+#include
+using namespace metal;
+
+struct ApollonianElevatorUniforms {
+ float time;
+ uint viewCount;
+ float boxScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ApollonianElevatorVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float3 AE_BOX_HALF = float3(1.0f);
+static constant float AE_SCENE_SCALE = 3.2f;
+static constant float3 AE_SCENE_OFFSET = float3(1.0f, 1.0f, 1.0f);
+static constant float AE_EPSILON = 0.002f;
+static constant float AE_MIN_STEP = 0.0005f;
+static constant float AE_MAX_TRACE_DISTANCE = 14.0f;
+static constant int AE_MAX_TRACE_STEPS = 100;
+
+vertex ApollonianElevatorVertexOut apollonianElevatorVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ApollonianElevatorUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz;
+
+ ApollonianElevatorVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float3 aeMod(float3 x, float y) {
+ return x - y * floor(x / y);
+}
+
+static float aeMap(float3 p, float time) {
+ p.y += 0.2f * time;
+
+ float scale = 1.0f;
+ for (int i = 0; i < 7; ++i) {
+ p = aeMod(p - 1.0f, 2.0f) - 1.0f;
+ float invRadius2 = max(dot(p, p), 1.0e-4f);
+ float k = 1.5f / invRadius2;
+ p *= k;
+ scale *= k;
+ }
+
+ return length(p) / scale - 0.01f;
+}
+
+static float2 aeBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+static float3 aeNormal(float3 p, float time) {
+ float2 e = float2(0.003f, 0.0f);
+ return normalize(float3(
+ aeMap(p + e.xyy, time) - aeMap(p - e.xyy, time),
+ aeMap(p + e.yxy, time) - aeMap(p - e.yxy, time),
+ aeMap(p + e.yyx, time) - aeMap(p - e.yyx, time)));
+}
+
+fragment float4 apollonianElevatorFragment(
+ ApollonianElevatorVertexOut in [[stage_in]],
+ constant ApollonianElevatorUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float scale = max(uniforms.boxScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / scale;
+ float3 hit = (in.worldPos - center) / scale;
+ float3 rd = normalize(hit - eye);
+
+ bool insideBox = all(abs(eye) < AE_BOX_HALF - 1.0e-3f);
+ float2 tBox = aeBoxIntersect(eye, rd, AE_BOX_HALF);
+ if (!insideBox && tBox.x > tBox.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f);
+ float tEnd = tBox.y;
+ if (tEnd <= tStart) {
+ discard_fragment();
+ }
+
+ float3 marchOrigin = eye + rd * (tStart + AE_EPSILON);
+ float3 ro = marchOrigin * AE_SCENE_SCALE + AE_SCENE_OFFSET;
+ float traceLimit = min(AE_MAX_TRACE_DISTANCE, (tEnd - tStart) * AE_SCENE_SCALE);
+
+ float distanceTraveled = 0.0f;
+ float prevDistanceField = 0.0f;
+ float stepDistance = 0.0f;
+ float3 p = ro;
+
+ for (int i = 0; i < AE_MAX_TRACE_STEPS; ++i) {
+ p = ro + rd * distanceTraveled;
+ float distanceField = aeMap(p, uniforms.time);
+ prevDistanceField = distanceField;
+ if (distanceField < AE_MIN_STEP || distanceTraveled > traceLimit) {
+ break;
+ }
+ stepDistance = max(distanceField, AE_MIN_STEP);
+ distanceTraveled += stepDistance;
+ }
+
+ if (distanceTraveled > traceLimit) {
+ discard_fragment();
+ }
+
+ float3 hitPoint = ro + rd * distanceTraveled;
+ float previousField = aeMap(hitPoint - rd * max(stepDistance, 0.02f), uniforms.time);
+
+ // Preserve the original compact color idea while stabilizing the divisor for
+ // arbitrary 3D viewing directions through the container.
+ float zDenominator = hitPoint.z >= 0.0f ? max(hitPoint.z, 0.35f) : min(hitPoint.z, -0.35f);
+ float3 color = (hitPoint * previousField - 2.0f) / zDenominator + 1.0f;
+
+ float3 normal = aeNormal(hitPoint, uniforms.time);
+ float3 lightDir = normalize(float3(0.45f, 0.82f, -0.34f));
+ float diffuse = max(dot(normal, lightDir), 0.0f);
+ float fresnel = pow(1.0f - max(dot(-rd, normal), 0.0f), 2.2f);
+ color *= 0.35f + 0.65f * diffuse;
+ color += float3(0.08f, 0.10f, 0.14f) * fresnel * 0.35f;
+ color += float3(0.03f, 0.02f, 0.05f) * clamp(1.0f - distanceTraveled / traceLimit, 0.0f, 1.0f);
+
+ return float4(clamp(color, 0.0f, 1.0f), 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/ApollonianElevator/ApollonianElevatorTypes.swift b/vr-dive/Demos/ApollonianElevator/ApollonianElevatorTypes.swift
new file mode 100644
index 0000000..20400bf
--- /dev/null
+++ b/vr-dive/Demos/ApollonianElevator/ApollonianElevatorTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct ApollonianElevatorUniforms in ApollonianElevatorShaders.metal.
+struct ApollonianElevatorUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var boxScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Renderer.swift b/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Renderer.swift
new file mode 100644
index 0000000..0250cb0
--- /dev/null
+++ b/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Renderer.swift
@@ -0,0 +1,170 @@
+import Metal
+import simd
+
+// ApollonianIIv4Renderer.swift
+//
+// Source reference:
+// https://www.shadertoy.com/view/WlcXR2
+// "Apollonian II" by inigo quilez - iq/2016
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported
+
+final class ApollonianIIv4Renderer: VisualPatternController {
+ let identifier: VisualPatternKind = .apollonianIIv4
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // 4 metre cube. Front face at z = objectCenter.z + cubeScale = -1.75 + 2.0 = +0.25
+ private let cubeScale: Float = 2.0
+ private let travelSpeed: Float = 0.5
+ private let objectCenter = SIMD3(0.0, 0.0, -1.75)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ApollonianIIv4Renderer.makeBox(device: device)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ApollonianIIv4Renderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = ApollonianIIv4Renderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.back)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ApollonianIIv4Uniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ travelSpeed: travelSpeed,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ApollonianIIv4Renderer {
+ fileprivate static func makeBox(
+ device: MTLDevice
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let x: Float = 1.0
+ let y: Float = 1.0
+ let z: Float = 1.0
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vBuf = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let iBuf = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vBuf, iBuf, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice, library: MTLLibrary, maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "apollonianIIv4Vertex")
+ desc.fragmentFunction = library.makeFunction(name: "apollonianIIv4Fragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vd = MTLVertexDescriptor()
+ vd.attributes[0].format = .float3
+ vd.attributes[0].offset = 0
+ vd.attributes[0].bufferIndex = 0
+ vd.attributes[1].format = .float3
+ vd.attributes[1].offset = MemoryLayout>.stride
+ vd.attributes[1].bufferIndex = 0
+ vd.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vd
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Shaders.metal b/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Shaders.metal
new file mode 100644
index 0000000..1987126
--- /dev/null
+++ b/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Shaders.metal
@@ -0,0 +1,267 @@
+// ApollonianIIv4Shaders.metal
+//
+// Source reference:
+// https://www.shadertoy.com/view/WlcXR2
+// "Apollonian II" by inigo quilez - iq/2016
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported
+//
+// Adapted for vr-dive: renders inside a view-independent 2 metre cube container.
+// Camera is driven by the visionOS head pose; the original ShaderToy camera orbit
+// is replaced by standard box-intersection ray marching.
+
+#include
+using namespace metal;
+
+// Scene is scaled so that the repeating Apollonian cell (period 2) comfortably
+// fills the cube. Smaller values = larger structures visible from outside.
+#define AP_SCENE_SCALE 1.0f
+#define AP_MAXD 20.0f
+#define AP_MAX_STEPS 256
+
+struct ApollonianIIv4Uniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float travelSpeed;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ApollonianIIv4VertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+// ---------------------------------------------------------------------------
+// Vertex shader
+// ---------------------------------------------------------------------------
+vertex ApollonianIIv4VertexOut apollonianIIv4Vertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ApollonianIIv4Uniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ ApollonianIIv4VertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Box intersection helper
+// ---------------------------------------------------------------------------
+static bool ap_boxHit(
+ float3 ro, float3 rd, float3 bmin, float3 bmax,
+ thread float &tNear, thread float &tFar)
+{
+ float3 t0 = (bmin - ro) / rd;
+ float3 t1 = (bmax - ro) / rd;
+ float3 lo = min(t0, t1);
+ float3 hi = max(t0, t1);
+ tNear = max(max(lo.x, lo.y), lo.z);
+ tFar = min(min(hi.x, hi.y), hi.z);
+ return tFar >= max(tNear, 0.0f);
+}
+
+static float ap_edgeDistance(float3 p) {
+ float3 a = abs(p);
+ if (a.x > a.y && a.x > a.z) return min(1.0f - a.y, 1.0f - a.z);
+ if (a.y > a.z) return min(1.0f - a.x, 1.0f - a.z);
+ return min(1.0f - a.x, 1.0f - a.y);
+}
+
+static float3 ap_faceNormal(float3 p) {
+ float3 a = abs(p);
+ if (a.x > a.y && a.x > a.z) return float3(sign(p.x), 0.0f, 0.0f);
+ if (a.y > a.z) return float3(0.0f, sign(p.y), 0.0f);
+ return float3(0.0f, 0.0f, sign(p.z));
+}
+
+// GLSL-compatible mod: x - y*floor(x/y), always non-negative when y > 0.
+// Metal's fmod() truncates toward zero and gives wrong results for negative x.
+static float3 glsl_mod(float3 x, float y) {
+ return x - y * floor(x / y);
+}
+
+// ---------------------------------------------------------------------------
+// Apollonian SDF (direct port of map() from ShaderToy WlcXR2)
+// Returns float3( distance, adr, k*0.5 )
+// ---------------------------------------------------------------------------
+static float3 ap_map(float3 ppp, float iTime)
+{
+ float3 p = ppp;
+
+ // Move scene forward instead of moving camera — original IQ trick.
+ p.z += iTime * 0.5f;
+
+ float i = 0.0f, s = 1.0f, k = 1.0f;
+
+ // Repeat Apollonian fractal — 6 iterations.
+ while (i++ < 6.0f) {
+ float3 pp = glsl_mod(p - 1.0f, 2.0f) - 1.0f;
+ p = pp;
+ k = 1.0f / dot(pp, p);
+ p *= k;
+ s *= k;
+ }
+
+ float a1 = dot(p.xy, p.xy);
+ float a2 = dot(p.yz, p.yz);
+ float a3 = dot(p.zx, p.zx);
+
+ float d1 = sqrt(min(min(a1, a2), a3)) - 0.11f;
+ float d2 = abs(p.y);
+ float dmi = d2;
+ float adr = 0.7f * fract((0.5f * p.y + 0.5f) * 8.0f);
+
+ if (d1 < d2) {
+ dmi = d1;
+ adr = 0.0f;
+ }
+
+ return float3(0.5f * dmi / s, adr, k * 0.5f);
+}
+
+// ---------------------------------------------------------------------------
+// Ray march (port of trace())
+// ---------------------------------------------------------------------------
+static float3 ap_trace(float3 ro, float3 rd, float tMax, float iTime)
+{
+ float t = 0.01f;
+ float2 info = float2(0.0f);
+ for (int i = 0; i < AP_MAX_STEPS; ++i) {
+ float precis = 0.001f * t;
+ float3 r = ap_map(ro + rd * t, iTime);
+ float h = r.x;
+ info = r.yz;
+ if (h < precis || t > tMax) break;
+ t += h;
+ }
+ if (t > tMax) t = -1.0f;
+ return float3(t, info);
+}
+
+// ---------------------------------------------------------------------------
+// Normal (port of calcNormal())
+// ---------------------------------------------------------------------------
+static float3 ap_normal(float3 pos, float t, float iTime)
+{
+ float precis = 0.0001f * t * 0.57f;
+ float2 e = float2(precis, -precis);
+ return normalize(
+ e.xyy * ap_map(pos + e.xyy, iTime).x +
+ e.yyx * ap_map(pos + e.yyx, iTime).x +
+ e.yxy * ap_map(pos + e.yxy, iTime).x +
+ e.xxx * ap_map(pos + e.xxx, iTime).x);
+}
+
+// Spherical Fibonacci direction (port of forwardSF())
+static float3 ap_forwardSF(float i, float n)
+{
+ const float PI = 3.141592653589793f;
+ const float PHI = 1.618033988749895f;
+ float phi = 2.0f * PI * fract(i / PHI);
+ float zi = 1.0f - (2.0f * i + 1.0f) / n;
+ float sinTheta = sqrt(1.0f - zi * zi);
+ return float3(cos(phi) * sinTheta, sin(phi) * sinTheta, zi);
+}
+
+// AO (port of calcAO())
+static float ap_ao(float3 pos, float3 nor, float iTime)
+{
+ float ao = 0.0f;
+ for (int i = 0; i < 16; ++i) {
+ float3 w = ap_forwardSF(float(i), 16.0f);
+ w *= sign(dot(w, nor));
+ float h = float(i) / 15.0f;
+ ao += clamp(ap_map(pos + nor * 0.1f + h, iTime).x * 2.0f, 0.0f, 1.0f);
+ }
+ ao /= 16.0f;
+ return clamp(ao * 16.0f, 0.0f, 1.0f);
+}
+
+// ---------------------------------------------------------------------------
+// Shade a hit point (port of render() body)
+// ---------------------------------------------------------------------------
+static float3 ap_shade(float3 ro, float3 rd, float3 res, float iTime)
+{
+ float3 col = float3(0.0f);
+ float t = res.x;
+ if (t > 0.0f) {
+ float3 pos = ro + t * rd;
+ float3 nor = ap_normal(pos, t, iTime);
+ float fre = clamp(1.0f + dot(rd, nor), 0.0f, 1.0f);
+ float occ = pow(clamp(res.z * 2.0f, 0.0f, 1.0f), 1.2f);
+ occ = 1.5f * (0.1f + 0.9f * occ) * ap_ao(pos, nor, iTime);
+ float3 lin = float3(1.0f, 1.0f, 1.5f)
+ * (2.0f + fre * fre * float3(1.8f, 1.0f, 1.0f))
+ * occ * (1.0f - 0.5f * abs(nor.y));
+
+ col = 0.5f + 0.5f * cos(6.2831f * res.y + float3(0.0f, 1.0f, 2.0f));
+ col = col * lin;
+ col += 0.6f * pow(1.0f - fre, 32.0f) * occ * float3(0.5f, 1.0f, 1.5f);
+ col *= exp(-0.3f * t);
+ }
+ col.z += 0.01f;
+ return sqrt(col);
+}
+
+// ---------------------------------------------------------------------------
+// Fragment shader
+// ---------------------------------------------------------------------------
+fragment float4 apollonianIIv4Fragment(
+ ApollonianIIv4VertexOut in [[stage_in]],
+ constant ApollonianIIv4Uniforms &uniforms [[buffer(0)]],
+ constant float4x4 *v2wMats [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = v2wMats[vi];
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 roLocal = (camWorld - center) / uniforms.cubeScale;
+ float3 rdLocal = normalize(in.worldPos - camWorld);
+
+ float tEntry, tExit;
+ if (!ap_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) {
+ discard_fragment();
+ }
+
+ // Subtle edge glow on the container faces
+ float3 entryLocal = roLocal + rdLocal * max(tEntry, 0.0f);
+ float3 faceNrm = ap_faceNormal(entryLocal);
+ float fresnel = pow(1.0f - max(0.0f, dot(-rdLocal, faceNrm)), 2.2f);
+ float edge = smoothstep(0.16f, 0.02f, ap_edgeDistance(entryLocal));
+
+ // Map entry point to scene space and march
+ float iTime = uniforms.time * uniforms.travelSpeed;
+ float3 roScene = entryLocal * AP_SCENE_SCALE;
+ float3 rdScene = normalize(rdLocal);
+ float travelLimit = min((tExit - max(tEntry, 0.0f)) * AP_SCENE_SCALE, AP_MAXD);
+
+ float3 res = ap_trace(roScene, rdScene, travelLimit, iTime);
+
+ float3 color = ap_shade(roScene, rdScene, res, iTime);
+
+ // If ray missed (hit back wall / travelLimit), darken to near-black
+ if (res.x < 0.0f) {
+ color = float3(0.0f, 0.0f, 0.01f);
+ }
+
+ // Very subtle container boundary hints — barely visible
+ color += float3(0.04f, 0.08f, 0.16f) * fresnel * 0.08f;
+ color += float3(0.20f, 0.30f, 0.50f) * edge * 0.04f;
+
+ return float4(color, 1.0f);
+}
diff --git a/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Types.swift b/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Types.swift
new file mode 100644
index 0000000..0284ba3
--- /dev/null
+++ b/vr-dive/Demos/ApollonianIIv4/ApollonianIIv4Types.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct ApollonianIIv4Uniforms in
+/// ApollonianIIv4Shaders.metal.
+struct ApollonianIIv4Uniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var travelSpeed: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/ApollonianTwist/ApollonianTwistRenderer.swift b/vr-dive/Demos/ApollonianTwist/ApollonianTwistRenderer.swift
new file mode 100644
index 0000000..6249980
--- /dev/null
+++ b/vr-dive/Demos/ApollonianTwist/ApollonianTwistRenderer.swift
@@ -0,0 +1,175 @@
+import Metal
+import simd
+
+// ApollonianTwistRenderer.swift
+// 3D cube-container adaptation of a twisted Apollian field.
+
+final class ApollonianTwistRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .apollonianTwist
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // Cube with 1.5 m side length.
+ private let cubeScale: Float = 0.75
+ private let objectCenter = SIMD3(0.0, 0.0, -2.0)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ApollonianTwistRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0))
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ApollonianTwistRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = ApollonianTwistRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0) * 0.5
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ApollonianTwistUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ApollonianTwistRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "apollonianTwistVertex")
+ desc.fragmentFunction = library.makeFunction(name: "apollonianTwistFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/ApollonianTwist/ApollonianTwistShaders.metal b/vr-dive/Demos/ApollonianTwist/ApollonianTwistShaders.metal
new file mode 100644
index 0000000..6e85de9
--- /dev/null
+++ b/vr-dive/Demos/ApollonianTwist/ApollonianTwistShaders.metal
@@ -0,0 +1,304 @@
+// ApollonianTwistShaders.metal
+// 3D cube-container adaptation of a twisted Apollian field.
+//
+// This combines the existing 3D Apollonian raymarch structure with the
+// rotating 4D Apollian fold used by the planar "Apollian with a twist" code.
+
+#include
+using namespace metal;
+
+struct ApollonianTwistUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ApollonianTwistVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+struct ApollonianTwistMapData {
+ float dist;
+ float4 trap;
+ float detail;
+};
+
+static constant float3 AT_BOX_HALF = float3(1.0f);
+static constant float AT_SCENE_SCALE = 1.3125f;
+static constant float AT_MAX_DISTANCE = 7.5f;
+static constant int AT_MAX_TRACE_STEPS = 190;
+static constant int AT_APOLLIAN_ITERS = 12;
+
+vertex ApollonianTwistVertexOut apollonianTwistVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ApollonianTwistUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ ApollonianTwistVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float2 atRotate(float2 p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ return float2(c * p.x + s * p.y, -s * p.x + c * p.y);
+}
+
+static float atPsin(float x) {
+ return 0.5f + 0.5f * sin(x);
+}
+
+static float atTanhApprox(float x) {
+ float x2 = x * x;
+ return clamp(x * (27.0f + x2) / (27.0f + 9.0f * x2), -1.0f, 1.0f);
+}
+
+static float2 atBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+static float atApollian(float4 p, float s, thread float4 &trap, thread float &detail) {
+ float scale = 1.0f;
+ trap = float4(1000.0f);
+ detail = 0.0f;
+
+ for (int i = 0; i < AT_APOLLIAN_ITERS; ++i) {
+ p = -1.0f + 2.0f * fract(0.5f * p + 0.5f);
+ float r2 = max(dot(p, p), 1.0e-5f);
+ trap = min(trap, float4(abs(p.x), abs(p.y), abs(p.z), r2));
+ float iterWeight = float(i + 1) / float(AT_APOLLIAN_ITERS);
+ detail = max(detail, iterWeight * exp(-1.25f * r2));
+ float k = s / r2;
+ p *= k;
+ scale *= k;
+ }
+
+ detail = clamp(max(detail, clamp(log2(max(scale, 1.0f)) / 24.0f, 0.0f, 1.0f)), 0.0f, 1.0f);
+
+ return abs(p.y) / scale;
+}
+
+static ApollonianTwistMapData atCluster(
+ float3 p,
+ float time,
+ float phase,
+ float thickness,
+ float radius)
+{
+ float tm = 0.22f * time;
+ float3 q = p;
+ // Remove the local position-coupled twist terms that generated broad ripple bands.
+ q.xy = atRotate(q.xy, tm * 0.40f + 0.55f * phase);
+ q.yz = atRotate(q.yz, tm * 0.24f - 0.28f * phase);
+ q.xz = atRotate(q.xz, tm * 0.16f + 0.22f * phase);
+
+ float r = 0.32f;
+ float3 off = float3(
+ r * atPsin(tm * sqrt(3.0f) + 0.9f * phase),
+ r * atPsin(tm * sqrt(1.5f) - 0.7f * phase),
+ r * atPsin(tm * sqrt(2.0f) + 0.5f * phase));
+
+ float4 pp = float4(q + off, 0.0f);
+ pp.w = 0.055f * (1.0f - atTanhApprox(0.82f * length(pp.xyz)));
+ pp.yz = atRotate(pp.yz, tm * 0.52f + 0.14f * phase);
+ pp.xz = atRotate(pp.xz, tm * 0.35f - 0.10f * phase);
+ pp.xw = atRotate(pp.xw, -tm * 0.46f + 0.38f * phase);
+ pp.yw = atRotate(pp.yw, tm * 0.64f - 0.22f * phase);
+
+ const float zoom = 4.10f;
+ pp /= zoom;
+
+ float4 trap;
+ float detail;
+ float fractal = atApollian(pp, 1.24f, trap, detail) * zoom - thickness;
+ float bound = length(p) - radius;
+
+ ApollonianTwistMapData result;
+ result.dist = max(fractal, bound);
+ result.trap = trap;
+ result.detail = detail;
+ return result;
+}
+
+static ApollonianTwistMapData atMap(float3 p, float time) {
+ ApollonianTwistMapData result = atCluster(p, time, 0.0f, 0.0048f, 1.08f);
+
+ float3 sat1Center = float3(1.05f, 0.28f, -0.22f);
+ float sat1Scale = 0.34f;
+ float3 sat1Local = (p - sat1Center) / sat1Scale;
+ float sat1Gate = (length(sat1Local) - 1.05f) * sat1Scale;
+ if (sat1Gate < 0.20f) {
+ ApollonianTwistMapData sat1 = atCluster(sat1Local, time, 1.7f, 0.0040f, 0.92f);
+ sat1.dist *= sat1Scale;
+ if (sat1.dist < result.dist) {
+ result = sat1;
+ }
+ }
+
+ float3 sat2Center = float3(-0.90f, -0.42f, 0.30f);
+ float sat2Scale = 0.29f;
+ float3 sat2Local = (p - sat2Center) / sat2Scale;
+ float sat2Gate = (length(sat2Local) - 1.05f) * sat2Scale;
+ if (sat2Gate < 0.18f) {
+ ApollonianTwistMapData sat2 = atCluster(sat2Local, time, -2.0f, 0.0038f, 0.88f);
+ sat2.dist *= sat2Scale;
+ if (sat2.dist < result.dist) {
+ result = sat2;
+ }
+ }
+ return result;
+}
+
+static float atMapDistance(float3 p, float time) {
+ return atMap(p, time).dist;
+}
+
+static float atTrace(
+ float3 ro,
+ float3 rd,
+ float time,
+ float maxDistance,
+ thread float4 &trapOut)
+{
+ float t = 0.0f;
+ trapOut = float4(1000.0f);
+
+ for (int i = 0; i < AT_MAX_TRACE_STEPS; ++i) {
+ ApollonianTwistMapData hit = atMap(ro + rd * t, time);
+ trapOut = min(trapOut, hit.trap);
+ // The compact outer container reduces shaded area at the same distance;
+ // in exchange we keep a denser fold count here.
+ float precis = 0.00035f + 0.00022f * t;
+ if (hit.dist < precis || t > maxDistance || t > AT_MAX_DISTANCE) {
+ break;
+ }
+ t += clamp(hit.dist * 0.68f, 0.0025f, 0.14f);
+ }
+
+ if (t > min(maxDistance, AT_MAX_DISTANCE)) {
+ return -1.0f;
+ }
+ return t;
+}
+
+static float3 atCalcNormal(float3 pos, float time, float eps) {
+ float2 e = float2(eps, -eps);
+ return normalize(
+ e.xyy * atMapDistance(pos + e.xyy, time) +
+ e.yyx * atMapDistance(pos + e.yyx, time) +
+ e.yxy * atMapDistance(pos + e.yxy, time) +
+ e.xxx * atMapDistance(pos + e.xxx, time));
+}
+
+static float3 atBackground(float3 rd) {
+ float up = rd.y * 0.5f + 0.5f;
+ float3 sky = mix(float3(0.00012f, 0.00014f, 0.00018f), float3(0.0010f, 0.0012f, 0.0016f), up);
+ float glow = pow(max(1.0f - abs(rd.y), 0.0f), 3.2f);
+ sky += glow * float3(0.00065f, 0.00040f, 0.00095f);
+ return sqrt(max(sky, 0.0f));
+}
+
+static float3 atRender(float3 ro, float3 rd, float time, float maxDistance) {
+ float4 trap;
+ float t = atTrace(ro, rd, time, maxDistance, trap);
+ if (t <= 0.0f) {
+ return atBackground(rd);
+ }
+
+ float3 pos = ro + rd * t;
+ ApollonianTwistMapData hit = atMap(pos, time);
+ float3 nor = atCalcNormal(pos, time, max(0.0014f, 0.00045f * t));
+
+ float3 light1 = normalize(float3(0.55f, 0.72f, -0.42f));
+ float3 light2 = normalize(float3(-0.48f, 0.28f, 0.83f));
+ float key = clamp(dot(nor, light1), 0.0f, 1.0f);
+ float fill = clamp(0.2f + 0.8f * dot(nor, light2), 0.0f, 1.0f);
+ float rim = pow(1.0f - max(dot(-rd, nor), 0.0f), 2.5f);
+
+ float ao = pow(clamp(trap.w * 1.55f, 0.0f, 1.0f), 0.72f);
+ float detail = clamp(hit.detail, 0.0f, 1.0f);
+ float3 deepGreen = float3(0.010f, 0.055f, 0.028f);
+ float3 midGreen = float3(0.15f, 0.33f, 0.12f);
+ float3 gold = float3(1.00f, 0.82f, 0.26f);
+ float3 warmWhite = float3(0.985f, 0.985f, 0.965f);
+ float3 base = mix(deepGreen, midGreen, sqrt(detail));
+ float3 brightCore = mix(gold, warmWhite, smoothstep(0.82f, 1.0f, detail));
+ float3 highlight = mix(midGreen, brightCore, pow(detail, 1.35f));
+
+ float keyBand = 0.04f + 1.10f * pow(key, 1.48f);
+ float fillBand = 0.02f + 0.13f * pow(fill, 1.14f);
+ float whiteLift = smoothstep(0.86f, 1.0f, detail) * (0.30f + 0.70f * key) * ao;
+
+ float3 col = base * keyBand * ao;
+ col += highlight * fillBand * ao;
+ col += highlight * rim * (0.025f + 0.12f * detail);
+ col += brightCore * (0.05f + 0.18f * detail)
+ * (exp(-18.0f * trap.x) + 0.45f * exp(-30.0f * trap.y));
+ col = mix(col, warmWhite, 0.72f * whiteLift);
+ col *= exp(-0.11f * t);
+ float3 shadowFloor = base * (0.010f + 0.006f * ao) + float3(0.00055f, 0.00070f, 0.00055f);
+ col = max((col - 0.17f) * 1.52f + 0.17f, shadowFloor);
+ return sqrt(max(col, 0.0f));
+}
+
+fragment float4 apollonianTwistFragment(
+ ApollonianTwistVertexOut in [[stage_in]],
+ constant ApollonianTwistUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 cameraWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float scale = max(uniforms.cubeScale, 1.0e-4f);
+ float3 eye = (cameraWorld - center) / scale;
+ float3 surfacePos = (in.worldPos - center) / scale;
+ float3 viewDir = normalize(surfacePos - eye);
+
+ bool insideOuter = all(abs(eye) < AT_BOX_HALF - 1.0e-3f);
+ float2 tOuter = atBoxIntersect(eye, viewDir, AT_BOX_HALF);
+ if (!insideOuter && tOuter.x > tOuter.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f);
+ float tEnd = tOuter.y;
+ if (tEnd <= tStart) {
+ discard_fragment();
+ }
+
+ float3 localOrigin = eye + viewDir * (tStart + 0.001f);
+ float3 sceneOrigin = localOrigin * AT_SCENE_SCALE;
+ float maxDistance = max((tEnd - tStart) * AT_SCENE_SCALE, 0.0f);
+
+ float time = uniforms.time;
+ float3 col = atRender(sceneOrigin, viewDir, time, maxDistance);
+
+ return float4(clamp(col, 0.0f, 1.0f), 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/ApollonianTwist/ApollonianTwistTypes.swift b/vr-dive/Demos/ApollonianTwist/ApollonianTwistTypes.swift
new file mode 100644
index 0000000..8a1649e
--- /dev/null
+++ b/vr-dive/Demos/ApollonianTwist/ApollonianTwistTypes.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct ApollonianTwistUniforms in
+/// ApollonianTwistShaders.metal.
+struct ApollonianTwistUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/ApollonianWires/ApollonianWiresRenderer.swift b/vr-dive/Demos/ApollonianWires/ApollonianWiresRenderer.swift
new file mode 100644
index 0000000..55f6e3c
--- /dev/null
+++ b/vr-dive/Demos/ApollonianWires/ApollonianWiresRenderer.swift
@@ -0,0 +1,176 @@
+import Metal
+import simd
+
+// ApollonianWiresRenderer.swift
+// 3D cube-container adaptation of an Apollonian wire fractal (ShaderToy Wlsfzs).
+// Original: https://www.shadertoy.com/view/Wlsfzs — author unknown.
+
+final class ApollonianWiresRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .apollonianWires
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // 2 m cube: half-extent 1.0 in local space × cubeScale 1.0 m
+ private let cubeScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -2.0)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = ApollonianWiresRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0))
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try ApollonianWiresRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = ApollonianWiresRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = ApollonianWiresUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension ApollonianWiresRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "apollonianWiresVertex")
+ desc.fragmentFunction = library.makeFunction(name: "apollonianWiresFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/ApollonianWires/ApollonianWiresShaders.metal b/vr-dive/Demos/ApollonianWires/ApollonianWiresShaders.metal
new file mode 100644
index 0000000..f333c0d
--- /dev/null
+++ b/vr-dive/Demos/ApollonianWires/ApollonianWiresShaders.metal
@@ -0,0 +1,193 @@
+// ApollonianWiresShaders.metal
+// 3D visionOS adaptation of an Apollonian wire fractal (ShaderToy Wlsfzs).
+//
+// Original GLSL source:
+// https://www.shadertoy.com/view/Wlsfzs
+// Author unknown — ported to Metal / visionOS cube-container ray march
+// by the vr-dive project.
+//
+// Rendering strategy: rasterise the 6 faces of a world-space cube; each
+// fragment reconstructs a ray from the camera through the cube surface and
+// marches inward. Inside-camera support is handled by setting tStart = 0.
+
+#include
+using namespace metal;
+
+// ---------------------------------------------------------------------------
+// Shared types (must match ApollonianWiresTypes.swift)
+// ---------------------------------------------------------------------------
+
+struct ApollonianWiresUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct ApollonianWiresVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+// ---------------------------------------------------------------------------
+// Vertex
+// ---------------------------------------------------------------------------
+
+vertex ApollonianWiresVertexOut apollonianWiresVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant ApollonianWiresUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ ApollonianWiresVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+// Rotation matrix — mirrors GLSL `#define rot(a) mat2(cos(a),sin(a),-sin(a),cos(a))`.
+//
+// GLSL convention: `p.xz *= rot(a)` is a row-vector × matrix operation.
+// Metal convention: `M * v` is matrix × column-vector.
+// With the same column-major construction float2x2(float2(c,s), float2(-s,c)):
+// (M*v)[0] = c*v.x + (-s)*v.z = same as GLSL result.x ✓
+// (M*v)[1] = s*v.x + c*v.z = same as GLSL result.z ✓
+static float2x2 awRot(float a) {
+ float c = cos(a), s = sin(a);
+ return float2x2(float2(c, s), float2(-s, c));
+}
+
+// Axis-aligned box intersection. Returns (tNear, tFar).
+static float2 awBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = ( halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+// ---------------------------------------------------------------------------
+// Distance field — direct port of GLSL map().
+//
+// Translation notes:
+// p.xz *= rot(t) → p.xz = awRot(t) * p.xz (see convention comment above)
+// p.xy *= rot(t) → p.xy = awRot(t) * p.xy
+// Both rotations are sequential; the second uses the x already modified by the first.
+// dot(p,p) is clamped to avoid division by zero in the fold.
+// ---------------------------------------------------------------------------
+
+static float awMap(float3 p, float t) {
+ p.xz = awRot(t * 0.5f) * p.xz;
+ p.xy = awRot(t * 0.5f) * p.xy;
+
+ float s = 2.0f;
+ p = abs(p);
+
+ bool modeA = (fract(t * 0.5f) < 0.7f);
+ for (int i = 0; i < 12; i++) {
+ p = 1.0f - abs(p - 1.0f);
+ float dd = max(dot(p, p), 1.0e-6f);
+ float r2;
+ if (modeA) {
+ r2 = 1.2f / dd;
+ } else {
+ r2 = (i % 3 == 1) ? 1.3f : 1.3f / dd;
+ }
+ p *= r2;
+ s *= r2;
+ }
+
+ return length(cross(p, normalize(float3(1.0f)))) / s - 0.003f;
+}
+
+// ---------------------------------------------------------------------------
+// Fragment
+// ---------------------------------------------------------------------------
+
+fragment float4 apollonianWiresFragment(
+ ApollonianWiresVertexOut in [[stage_in]],
+ constant ApollonianWiresUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float scale = max(uniforms.cubeScale, 1.0e-4f);
+ float3 halfExt = float3(1.0f); // local-space ±1 cube
+
+ // Camera and surface point in local cube space
+ float3 cameraWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float3 eye = (cameraWorld - center) / scale;
+ float3 surfacePos = (in.worldPos - center) / scale;
+ float3 viewDir = normalize(surfacePos - eye);
+
+ // Box intersection to clip the ray to the cube volume
+ bool insideBox = all(abs(eye) < halfExt - 1.0e-3f);
+ float2 tBox = awBoxIntersect(eye, viewDir, halfExt);
+
+ if (!insideBox && tBox.x > tBox.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f);
+ float tEnd = tBox.y;
+ if (tEnd <= tStart) {
+ discard_fragment();
+ }
+
+ // Entry point (tiny offset to avoid self-intersection artifacts)
+ float3 ro = eye + viewDir * (tStart + 0.001f);
+
+ // Scale to scene space where the fractal has interesting structure.
+ // The original camera sits at x ∈ [3, 8]; sceneScale = 5 maps the cube
+ // surface (±1 local) to ±5 scene units — right in that range.
+ const float sceneScale = 5.0f;
+ float3 roScene = ro * sceneScale;
+ float maxMarchDist = (tEnd - tStart) * sceneScale;
+
+ // Ray march — matches original loop structure: i tracks iterations for brightness.
+ float t = uniforms.time;
+ float3 p = roScene;
+ float h = 0.0f;
+ float hitIter = 120.0f;
+
+ for (float i = 1.0f; i < 120.0f; i += 1.0f) {
+ p = roScene + viewDir * h;
+ float d = awMap(p, t);
+ if (d < 0.0001f) {
+ hitIter = i;
+ break;
+ }
+ h += d;
+ if (h > maxMarchDist) {
+ // Passed through the cube without hitting — return background
+ return float4(0.0f, 0.0f, 0.0f, 0.0f);
+ }
+ }
+
+ // Color from original: 30 * vec3(cos(p*0.8)*0.5+0.5) / i
+ float3 col = 30.0f * (cos(p * 0.8f) * 0.5f + 0.5f) / hitIter;
+ col = clamp(col, 0.0f, 1.0f);
+ return float4(col, 1.0f);
+}
diff --git a/vr-dive/Demos/ApollonianWires/ApollonianWiresTypes.swift b/vr-dive/Demos/ApollonianWires/ApollonianWiresTypes.swift
new file mode 100644
index 0000000..cb95bd9
--- /dev/null
+++ b/vr-dive/Demos/ApollonianWires/ApollonianWiresTypes.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct ApollonianWiresUniforms in
+/// ApollonianWiresShaders.metal.
+struct ApollonianWiresUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/BlueFlower/BlueFlowerRenderer.swift b/vr-dive/Demos/BlueFlower/BlueFlowerRenderer.swift
new file mode 100644
index 0000000..90677f3
--- /dev/null
+++ b/vr-dive/Demos/BlueFlower/BlueFlowerRenderer.swift
@@ -0,0 +1,177 @@
+import Metal
+import simd
+
+// BlueFlowerRenderer.swift
+//
+// Cube-container adaptation of ShaderToy "Blue Flower" (ttG3Dd).
+// The visible container is a 2 m × 2 m × 2 m cube. Rays enter from the
+// visible cube surface, or start from the eye when the camera is inside.
+
+final class BlueFlowerRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .blueFlower
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let boxScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.5)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = BlueFlowerRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0) * 1.02)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try BlueFlowerRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = BlueFlowerRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = BlueFlowerUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ boxScale: boxScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension BlueFlowerRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for p in face.positions {
+ vertices.append(V(position: p, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "blueFlowerVertex")
+ desc.fragmentFunction = library.makeFunction(name: "blueFlowerFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/BlueFlower/BlueFlowerShaders.metal b/vr-dive/Demos/BlueFlower/BlueFlowerShaders.metal
new file mode 100644
index 0000000..233529a
--- /dev/null
+++ b/vr-dive/Demos/BlueFlower/BlueFlowerShaders.metal
@@ -0,0 +1,240 @@
+// BlueFlowerShaders.metal
+// Adapted from ShaderToy "Blue Flower".
+// Source: https://www.shadertoy.com/view/ttG3Dd
+//
+// Metal adaptation notes:
+// - The original shader layered many petal shells directly in screen space.
+// This version converts the effect into a world-space SDF ray march that is
+// sampled from the real per-eye view ray after intersecting a 2 m cube.
+// - Outside the cube, marching starts at the visible cube surface; inside the
+// cube, marching starts at the eye.
+// - The flower field continues beyond the container entry plane, so the
+// simulated content is not clipped to the cube volume.
+// - GLSL matrix macros and `fwidth`-based edge blending are rewritten as
+// explicit Metal helpers and shell thickness terms.
+
+#include
+using namespace metal;
+
+struct BlueFlowerUniforms {
+ float time;
+ uint viewCount;
+ float boxScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct BlueFlowerVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+struct BFHitInfo {
+ float distance;
+ float radius;
+ float3 rotatedPoint;
+ float3 normal;
+};
+
+static constant float3 BF_BOX_HALF = float3(1.0f);
+static constant float3 BF_BACKGROUND = float3(0.8f, 0.85f, 0.9f);
+static constant float3 BF_LIGHT = float3(0.26726124f, 0.53452248f, 0.80178373f);
+static constant float3 BF_LIGHT_COLOR = float3(0.9f, 0.8f, 0.5f);
+static constant int BF_LAYERS = 20;
+static constant int BF_TRACE_STEPS = 96;
+static constant float BF_TRACE_EPSILON = 0.0012f;
+static constant float BF_HIT_EPSILON = 0.0015f;
+static constant float BF_MAX_DISTANCE = 5.0f;
+static constant float BF_SHELL_THICKNESS = 0.012f;
+
+vertex BlueFlowerVertexOut blueFlowerVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant BlueFlowerUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz;
+
+ BlueFlowerVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float2 bfRotate2D(float2 p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ return float2(c * p.x - s * p.y, s * p.x + c * p.y);
+}
+
+static float3 bfHash33(float3 p3) {
+ p3 = fract(p3 * float3(0.1031f, 0.1030f, 0.0973f));
+ p3 += dot(p3, p3.yxz + 33.33f);
+ return fract((p3.xxy + p3.yxx) * p3.zyx);
+}
+
+static float2 bfFaceUV(float3 p) {
+ float3 ap = abs(p);
+ float2 uv;
+ if (ap.x >= ap.y && ap.x >= ap.z) {
+ uv = p.zy;
+ } else if (ap.y >= ap.z) {
+ uv = p.xz;
+ } else {
+ uv = p.xy;
+ }
+ return clamp(uv * 0.5f + 0.5f, 0.0f, 1.0f);
+}
+
+static float2 bfBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+static float3 bfRotatePoint(float3 p, float time, int layerIndex) {
+ float localTime = time * 0.05f + float(layerIndex) * 0.3f;
+ p.zy = bfRotate2D(p.zy, 2.2f);
+ p.xz = bfRotate2D(p.xz, -0.1f);
+ p.yx = bfRotate2D(p.yx, localTime);
+ p.xz = bfRotate2D(p.xz, sin(localTime * 8.145f) * 0.2f);
+ p.zy = bfRotate2D(p.zy, sin(localTime * 6.587f) * 0.2f);
+ return p;
+}
+
+static BFHitInfo bfLayerInfo(float3 p, float time, int layerIndex) {
+ BFHitInfo info;
+ float layer = float(layerIndex);
+ float radius = (layer * layer + 1.0f) * 0.05f;
+ float3 pr = bfRotatePoint(p, time, layerIndex);
+ float at = atan2(pr.x, pr.y);
+ float petalRadius = (sin(at * 5.0f) * 0.6f + 0.2f) * radius;
+
+ float shell = abs(length(p) - radius) - BF_SHELL_THICKNESS;
+ float petals = abs(pr.z) - petalRadius;
+ info.distance = max(shell, petals);
+ info.radius = radius;
+ info.rotatedPoint = pr;
+ info.normal = normalize(p);
+ return info;
+}
+
+static BFHitInfo bfMap(float3 p, float time) {
+ BFHitInfo best;
+ best.distance = 1.0e9f;
+ best.radius = 0.0f;
+ best.rotatedPoint = p;
+ best.normal = float3(0.0f, 1.0f, 0.0f);
+
+ for (int layer = 0; layer < BF_LAYERS; ++layer) {
+ BFHitInfo candidate = bfLayerInfo(p, time, layer);
+ if (candidate.distance < best.distance) {
+ best = candidate;
+ }
+ }
+ return best;
+}
+
+static float bfDistance(float3 p, float time) {
+ return bfMap(p, time).distance;
+}
+
+static float3 bfCalcNormal(float3 p, float time) {
+ float e = 0.002f;
+ return normalize(float3(
+ bfDistance(p + float3(e, 0.0f, 0.0f), time) - bfDistance(p - float3(e, 0.0f, 0.0f), time),
+ bfDistance(p + float3(0.0f, e, 0.0f), time) - bfDistance(p - float3(0.0f, e, 0.0f), time),
+ bfDistance(p + float3(0.0f, 0.0f, e), time) - bfDistance(p - float3(0.0f, 0.0f, e), time)));
+}
+
+fragment float4 blueFlowerFragment(
+ BlueFlowerVertexOut in [[stage_in]],
+ constant BlueFlowerUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float cubeScale = max(uniforms.boxScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / cubeScale;
+ float3 hit = (in.worldPos - center) / cubeScale;
+ float3 rd = normalize(hit - eye);
+
+ bool insideBox = all(abs(eye) < BF_BOX_HALF - 1.0e-3f);
+ float2 tBox = bfBoxIntersect(eye, rd, BF_BOX_HALF);
+ if (!insideBox && tBox.x > tBox.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f);
+ float3 ro = eye + rd * (tStart + BF_TRACE_EPSILON);
+
+ float zoom = exp(-uniforms.time * 0.1f);
+ float scale = mix(20.0f, 70.0f, zoom) / 55.0f;
+ ro *= scale;
+
+ float totalDistance = 0.0f;
+ float stepDistance = 0.0f;
+ float3 pos = ro;
+ BFHitInfo info;
+ info.distance = 1.0e9f;
+ int stepsTaken = 0;
+ bool didHit = false;
+
+ for (int step = 0; step < BF_TRACE_STEPS; ++step) {
+ stepsTaken = step;
+ pos = ro + rd * totalDistance;
+ info = bfMap(pos, uniforms.time);
+ stepDistance = max(info.distance * 0.6f, BF_TRACE_EPSILON);
+ if (info.distance < BF_HIT_EPSILON) {
+ didHit = true;
+ break;
+ }
+ totalDistance += stepDistance;
+ if (totalDistance > BF_MAX_DISTANCE) {
+ break;
+ }
+ }
+
+ float2 q = bfFaceUV(hit);
+ float3 color = BF_BACKGROUND;
+
+ if (didHit) {
+ float3 normal = bfCalcNormal(pos, uniforms.time);
+ float at = atan2(info.rotatedPoint.x, info.rotatedPoint.y);
+ float stripe = cos(at * 5.0f) * 20.0f;
+ stripe *= smoothstep(0.1f, 0.0f, abs(stripe / 30.0f));
+ float dotl = max(0.0f, dot(BF_LIGHT, normal) + stripe * 0.05f);
+
+ float ao = 1.0f - min(1.0f, exp(pos.z / max(info.radius, 1.0e-4f) - 1.0f));
+ float distFog = max(0.0f, 2.0f - info.rotatedPoint.z);
+ float3 albedo = float3(0.2f, 0.3f, 0.8f);
+ color = albedo * BF_LIGHT_COLOR * dotl * 3.0f + albedo * BF_BACKGROUND * 0.4f * ao;
+ color = mix(BF_BACKGROUND, color, exp(-distFog * 0.1f));
+ }
+
+ color = pow(clamp(color, 0.0f, 1.0f), float3(1.0f / 2.2f));
+ float2 uu = q - 0.5f;
+ color = mix(color, float3(0.0f), dot(uu, uu) * 0.5f);
+ color += (
+ bfHash33(float3(hit.x * 256.0f, hit.y * 256.0f, uniforms.time + hit.z * 256.0f)) -
+ 0.5f) * 0.02f;
+ return float4(clamp(color, 0.0f, 1.0f), 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/BlueFlower/BlueFlowerTypes.swift b/vr-dive/Demos/BlueFlower/BlueFlowerTypes.swift
new file mode 100644
index 0000000..6d24804
--- /dev/null
+++ b/vr-dive/Demos/BlueFlower/BlueFlowerTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct BlueFlowerUniforms in BlueFlowerShaders.metal.
+struct BlueFlowerUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var boxScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/BoxOfStars/BoxOfStarsRenderer.swift b/vr-dive/Demos/BoxOfStars/BoxOfStarsRenderer.swift
new file mode 100644
index 0000000..68030f4
--- /dev/null
+++ b/vr-dive/Demos/BoxOfStars/BoxOfStarsRenderer.swift
@@ -0,0 +1,177 @@
+import Metal
+import simd
+
+// BoxOfStarsRenderer.swift
+//
+// Cube-container adaptation of ShaderToy "Box of Stars" (NcsSz4).
+// The visible container is a 2 m × 2 m × 2 m cube. Rays enter from the
+// visible cube surface, or start from the eye when the camera is inside.
+
+final class BoxOfStarsRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .boxOfStars
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let boxScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.5)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = BoxOfStarsRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0) * 1.02)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try BoxOfStarsRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = BoxOfStarsRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = BoxOfStarsUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ boxScale: boxScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension BoxOfStarsRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for p in face.positions {
+ vertices.append(V(position: p, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "boxOfStarsVertex")
+ desc.fragmentFunction = library.makeFunction(name: "boxOfStarsFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/BoxOfStars/BoxOfStarsShaders.metal b/vr-dive/Demos/BoxOfStars/BoxOfStarsShaders.metal
new file mode 100644
index 0000000..52d7d76
--- /dev/null
+++ b/vr-dive/Demos/BoxOfStars/BoxOfStarsShaders.metal
@@ -0,0 +1,317 @@
+// BoxOfStarsShaders.metal
+// Adapted from ShaderToy "Box of Stars".
+// Source: https://www.shadertoy.com/view/NcsSz4
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
+//
+// Metal adaptation notes:
+// - The original shader already renders a glass box with a volumetric star field.
+// This version preserves that internal effect, but reconstructs the ray from
+// the real per-eye camera and uses the visible 2 m cube as the outer entry
+// container for all viewing directions.
+// - The internal glass box and volumetric pillars are simulated beyond the outer
+// cube boundary so the effect itself is not clipped by the container volume.
+// - GLSL matrix/vector multiplication and out parameters are rewritten to match
+// Metal semantics.
+
+#include
+using namespace metal;
+
+struct BoxOfStarsUniforms {
+ float time;
+ uint viewCount;
+ float boxScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct BoxOfStarsVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float BOS_TSHIFT = 53.0f;
+static constant float BOS_PI = 3.1415926f;
+static constant float BOS_IOR = 1.33f;
+static constant float3 BOS_DIMS = float3(0.75f, 0.75f, 1.25f);
+static constant float3 BOS_OUTER_BOX_HALF = float3(1.0f);
+
+vertex BoxOfStarsVertexOut boxOfStarsVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant BoxOfStarsUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz;
+
+ BoxOfStarsVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float dot2(float3 v) {
+ return dot(v, v);
+}
+
+static float2 bosRotate(float2 v, float angle) {
+ float s = sin(angle);
+ float c = cos(angle);
+ return float2(v.x * c - v.y * s, v.x * s + v.y * c);
+}
+
+static float3 bosRotateZVec(float3 v, float angle) {
+ float2 xy = bosRotate(v.xy, angle);
+ return float3(xy.x, xy.y, v.z);
+}
+
+static float segShadow(float3 ro, float3 rd, float3 pa, float sh) {
+ float dm = dot(rd.yz, rd.yz);
+ float k1 = (ro.x - pa.x) * dm;
+ float k2 = (ro.x + pa.x) * dm;
+ float2 k5 = (ro.yz + pa.yz) * dm;
+ float k3 = dot(ro.yz + pa.yz, rd.yz);
+ float2 k4 = (pa.yz + pa.yz) * rd.yz;
+ float2 k6 = (pa.yz + pa.yz) * dm;
+
+ for (int i = 0; i < 4; ++i) {
+ float2 s = float2(float(i & 1), float(i >> 1));
+ float t = dot(s, k4) - k3;
+ if (t > 0.0f) {
+ float3 term = float3(clamp(-rd.x * t, k1, k2), k5 - k6 * s) + rd * t;
+ sh = min(sh, dot2(term) / max(t * t, 1.0e-6f));
+ }
+ }
+ return sh;
+}
+
+static float boxSoftShadow(float3 ro, float3 rd, float3 rad, float sk) {
+ rd += 0.0001f * (1.0f - abs(sign(rd)));
+ float3 m = 1.0f / rd;
+ float3 n = m * ro;
+ float3 k = abs(m) * rad;
+
+ float3 t1 = -n - k;
+ float3 t2 = -n + k;
+
+ float tN = max(max(t1.x, t1.y), t1.z);
+ float tF = min(min(t2.x, t2.y), t2.z);
+ if (tN < tF && tF > 0.0f) {
+ return 0.0f;
+ }
+
+ float sh = 1.0f;
+ sh = segShadow(ro.xyz, rd.xyz, rad.xyz, sh);
+ sh = segShadow(ro.yzx, rd.yzx, rad.yzx, sh);
+ sh = segShadow(ro.zxy, rd.zxy, rad.zxy, sh);
+ sh = clamp(sk * sqrt(sh), 0.0f, 1.0f);
+ return sh * sh * (3.0f - 2.0f * sh);
+}
+
+static float boxIntersect(float3 ro, float3 rd, float3 r, thread float3 &nn, bool entering) {
+ rd += 0.0001f * (1.0f - abs(sign(rd)));
+ float3 dr = 1.0f / rd;
+ float3 n = ro * dr;
+ float3 k = r * abs(dr);
+
+ float3 pin = -k - n;
+ float3 pout = k - n;
+ float tin = max(pin.x, max(pin.y, pin.z));
+ float tout = min(pout.x, min(pout.y, pout.z));
+ if (tin > tout) {
+ return -1.0f;
+ }
+
+ if (entering) {
+ nn = -sign(rd) * step(pin.zxy, pin.xyz) * step(pin.yzx, pin.xyz);
+ return tin;
+ }
+
+ nn = sign(rd) * step(pout.xyz, pout.zxy) * step(pout.xyz, pout.yzx);
+ return tout;
+}
+
+static float2 outerBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+static float3 bosToOriginalScene(float3 v) {
+ // The source shader is z-up. Rotate the reconstructed VR scene so the
+ // apparent floor sits below the viewer and the glass box stands upright.
+ return float3(v.x, -v.z, v.y);
+}
+
+static float3 bgcol(float3 rd) {
+ return mix(float3(0.01f), float3(0.336f, 0.458f, 0.668f),
+ 1.0f - pow(abs(rd.z + 0.25f), 1.3f));
+}
+
+static float3 background(float3 ro, float3 rd, float3 l_dir, thread float &alpha) {
+ float t = (-BOS_DIMS.z - ro.z) / rd.z;
+ alpha = 0.0f;
+ float3 bgc = bgcol(rd);
+ if (t < 0.0f) {
+ return bgc;
+ }
+
+ float2 uv = ro.xy + t * rd.xy;
+ float3 lightDir = normalize(bosRotateZVec(l_dir + float3(0.0f, 0.0f, 1.0f), BOS_PI * 0.65f));
+ float shad = boxSoftShadow(ro + t * rd, lightDir, BOS_DIMS, 1.5f);
+ float aofac = smoothstep(-0.95f, 0.75f, length(abs(uv) - min(abs(uv), float2(0.45f))));
+ aofac = min(aofac, smoothstep(-0.65f, 1.0f, shad));
+ float lght = max(dot(normalize(ro + t * rd + float3(0.0f, 0.0f, -5.0f)),
+ normalize(bosRotateZVec(l_dir - float3(0.0f, 0.0f, 1.0f), BOS_PI * 0.65f))),
+ 0.0f);
+ float3 col = mix(float3(0.4f), float3(0.71f, 0.772f, 0.895f), lght * lght * aofac + 0.05f) * aofac;
+ alpha = 1.0f - smoothstep(7.0f, 10.0f, length(uv));
+ return mix(col * length(col) * 0.8f, bgc, smoothstep(7.0f, 10.0f, length(uv)));
+}
+
+static float3 hash33(float3 p3) {
+ p3 = fract(p3 * float3(0.1031f, 0.1030f, 0.0973f));
+ p3 += dot(p3, p3.yxz + 33.33f);
+ return fract((p3.xxy + p3.yxx) * p3.zyx);
+}
+
+static float3 waveDeform(float3 pos, float tOffset) {
+ float3 deformed = pos;
+ deformed.xz = bosRotate(deformed.xz, 0.4f);
+ deformed += cos(deformed.zxy);
+ deformed.xz = bosRotate(deformed.xz, 0.4f);
+ deformed += cos(deformed.zxy * 2.0f - tOffset * 2.0f) * 0.5f;
+ return deformed;
+}
+
+static float3 boxedStars(float3 ro, float3 rd, float time) {
+ float tmod = fmod((time + BOS_TSHIFT) * 0.33f, 1000.0f);
+ float starAccum = 0.0f;
+ const int maxSteps = 150;
+ const float stepSize = 0.0075f;
+ float vt = 0.0f;
+ float3 pillarAccum = float3(0.0f);
+ const float pillarWidth = 2.0f;
+ const float3 ambientBG = float3(0.0345f, 0.036f, 0.0915f);
+
+ for (int i = 0; i < maxSteps; ++i) {
+ float3 vp = ro + rd * vt;
+ vp.yz = vp.zy;
+ float3 p = vp * 10.0f;
+ float3 id = floor(p);
+ float3 q = fract(p) - 0.5f;
+ float3 h = hash33(id);
+
+ float3 pillarPos = p * 0.3f;
+ float3 lightPillar = waveDeform(pillarPos + float3(0.0f, tmod, 0.0f), tmod);
+
+ float2 cosPair = cos(lightPillar.xz);
+ float innerBeamsDist = length(cosPair);
+ float radialBound = length(pillarPos.xz) - pillarWidth;
+ float pillarDist = max(innerBeamsDist, radialBound);
+ float density = 0.02f / max(pillarDist, 0.001f);
+ float3 pillarGradient = mix(float3(0.0f, 0.1f, 1.0f), float3(0.9f, 0.0f, 1.0f),
+ smoothstep(-3.5f, 3.5f, pillarPos.y));
+
+ if (pillarDist < 1.0f) {
+ float3 gradxDens = pillarGradient * density;
+ const float px = 1.2f;
+ pillarAccum += clamp(float3(pow(gradxDens.x, px), pow(gradxDens.y, px), pow(gradxDens.z, px)), 0.0f, 1.0f);
+ }
+
+ if (h.z < 0.08f) {
+ float3 movement = sin(tmod * (h * 2.0f + 1.0f)) * 0.15f;
+ float3 offset = (h - 0.5f) * 0.3f + movement;
+ float d = length(q - offset);
+ float glow = pow(clamp(1.0f - d * 10.0f, 0.0f, 1.0f), 4.0f) * 2.0f;
+ float fade = clamp(1.0f - vt * 1.2f, 0.0f, 1.0f);
+ starAccum += glow * fade;
+ }
+
+ vt += stepSize;
+ }
+
+ float3 pillarColor = clamp(tanh(pillarAccum * 0.5f), 0.0f, 1.0f);
+ float3 finalColor = ambientBG + float3(starAccum) + pillarColor;
+ return clamp(finalColor, 0.0f, 1.0f);
+}
+
+fragment float4 boxOfStarsFragment(
+ BoxOfStarsVertexOut in [[stage_in]],
+ constant BoxOfStarsUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float cubeScale = max(uniforms.boxScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / cubeScale;
+ float3 hit = (in.worldPos - center) / cubeScale;
+ float3 rdOuter = normalize(hit - eye);
+
+ bool insideOuter = all(abs(eye) < BOS_OUTER_BOX_HALF - 1.0e-3f);
+ float2 tOuter = outerBoxIntersect(eye, rdOuter, BOS_OUTER_BOX_HALF);
+ if (!insideOuter && tOuter.x > tOuter.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f);
+ const float sceneScale = 2.0f;
+ float3 roScene = bosToOriginalScene((eye + rdOuter * (tStart + 0.001f)) * sceneScale);
+ float3 rdScene = normalize(bosToOriginalScene(rdOuter));
+
+ float3 l_dir = normalize(bosRotateZVec(float3(0.0f, 1.0f, 0.0f), 0.5f));
+
+ bool insideGlass = all(abs(roScene) < BOS_DIMS - 1.0e-3f);
+ float3 ni = insideGlass ? -rdScene : float3(0.0f, 0.0f, 1.0f);
+ float tGlass = 0.0f;
+ float fadeborders = 1.0f;
+ float3 glassSurface = roScene;
+
+ if (!insideGlass) {
+ tGlass = boxIntersect(roScene, rdScene, BOS_DIMS, ni, true);
+ if (tGlass > 0.0f) {
+ glassSurface = roScene + tGlass * rdScene;
+ float2 coords = glassSurface.xy * ni.z / BOS_DIMS.xy
+ + glassSurface.yz * ni.x / BOS_DIMS.yz
+ + glassSurface.zx * ni.y / BOS_DIMS.zx;
+ fadeborders = (1.0f - smoothstep(0.915f, 1.05f, abs(coords.x)))
+ * (1.0f - smoothstep(0.915f, 1.05f, abs(coords.y)));
+ }
+ }
+
+ if (!insideGlass && tGlass <= 0.0f) {
+ float alpha;
+ float3 bg = background(roScene, rdScene, l_dir, alpha);
+ return float4(clamp(bg, 0.0f, 1.0f), 1.0f);
+ }
+
+ float R0 = (BOS_IOR - 1.0f) / (BOS_IOR + 1.0f);
+ R0 *= R0;
+ float3 nr = ni;
+ float3 rdr = reflect(rdScene, nr);
+ float talpha;
+ float3 reflcol = background(glassSurface, rdr, l_dir, talpha);
+ float3 interiorCol = boxedStars(glassSurface + rdScene * 0.001f, rdScene, uniforms.time);
+ float fresnel = R0 + (1.0f - R0) * pow(1.0f - clamp(dot(-rdScene, nr), 0.0f, 1.0f), 5.0f);
+ float3 col = mix(interiorCol * fadeborders, reflcol, pow(fresnel, 1.5f));
+ col = clamp(col, 0.0f, 1.0f);
+
+ return float4(col, 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/BoxOfStars/BoxOfStarsTypes.swift b/vr-dive/Demos/BoxOfStars/BoxOfStarsTypes.swift
new file mode 100644
index 0000000..aae97b4
--- /dev/null
+++ b/vr-dive/Demos/BoxOfStars/BoxOfStarsTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct BoxOfStarsUniforms in BoxOfStarsShaders.metal.
+struct BoxOfStarsUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var boxScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/BubbleRings/BubbleRingsRenderer.swift b/vr-dive/Demos/BubbleRings/BubbleRingsRenderer.swift
new file mode 100644
index 0000000..ddec925
--- /dev/null
+++ b/vr-dive/Demos/BubbleRings/BubbleRingsRenderer.swift
@@ -0,0 +1,177 @@
+import Metal
+import simd
+
+// BubbleRingsRenderer.swift
+//
+// Cube-container adaptation of ShaderToy "WdB3Dw".
+// The visible container is a 2 m × 2 m × 2 m cube. Rays march from the
+// visible cube surface, or from the eye when the camera is inside.
+
+final class BubbleRingsRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .bubbleRings
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let boxScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -1.5)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = BubbleRingsRenderer.makeBox(
+ device: device,
+ localHalfExtents: SIMD3(repeating: 1.0) * 1.02)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try BubbleRingsRenderer.makePipelineState(
+ device: device,
+ library: library,
+ maxViewCount: self.maxViewCount)
+ depthStencilState = BubbleRingsRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = BubbleRingsUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ boxScale: boxScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms,
+ length: MemoryLayout.stride,
+ index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension BubbleRingsRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for p in face.positions {
+ vertices.append(V(position: p, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "bubbleRingsVertex")
+ desc.fragmentFunction = library.makeFunction(name: "bubbleRingsFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/BubbleRings/BubbleRingsShaders.metal b/vr-dive/Demos/BubbleRings/BubbleRingsShaders.metal
new file mode 100644
index 0000000..0b8c5d4
--- /dev/null
+++ b/vr-dive/Demos/BubbleRings/BubbleRingsShaders.metal
@@ -0,0 +1,203 @@
+// BubbleRingsShaders.metal
+// Adapted from ShaderToy "WdB3Dw".
+// Source: https://www.shadertoy.com/view/WdB3Dw
+// Uses pieces from:
+// - HG_SDF helpers: https://www.shadertoy.com/view/Xs3GRB
+// - Spectrum palette by IQ: https://www.shadertoy.com/view/ll2GD3
+// - Main SDF reference: https://www.shadertoy.com/view/wsfGDS
+// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
+//
+// Metal adaptation notes:
+// - The original shader uses a synthetic look-at camera. This version uses the
+// real per-eye world ray intersected with a 2 m cube container.
+// - Outside the cube, marching starts at the visible cube surface; inside the
+// cube, marching starts at the eye.
+// - The glow accumulation continues beyond the container entry plane, so the
+// simulated rings are not clipped by the cube volume.
+
+#include
+using namespace metal;
+
+struct BubbleRingsUniforms {
+ float time;
+ uint viewCount;
+ float boxScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct BubbleRingsVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float BR_PI = 3.14159265359f;
+static constant float3 BR_BOX_HALF = float3(1.0f);
+static constant float BR_SCENE_SCALE = 2.4f;
+static constant float BR_ITER = 82.0f;
+static constant float BR_FUDGE_FACTOR = 0.8f;
+static constant float BR_INTERSECTION_PRECISION = 0.001f;
+static constant float BR_MAX_DIST = 20.0f;
+static constant float BR_TRACE_EPSILON = 0.002f;
+
+vertex BubbleRingsVertexOut bubbleRingsVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant BubbleRingsUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz;
+
+ BubbleRingsVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static void brRotate(thread float2 &p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ p = c * p + s * float2(p.y, -p.x);
+}
+
+static float brSmax(float a, float b, float r) {
+ float2 u = max(float2(r + a, r + b), float2(0.0f));
+ return min(-r, max(a, b)) + length(u);
+}
+
+static float3 brPal(float t, float3 a, float3 b, float3 c, float3 d) {
+ return a + b * cos(6.28318f * (c * t + d));
+}
+
+static float3 brSpectrum(float n) {
+ return brPal(
+ n,
+ float3(0.5f, 0.5f, 0.5f),
+ float3(0.5f, 0.5f, 0.5f),
+ float3(1.0f, 1.0f, 1.0f),
+ float3(0.0f, 0.33f, 0.67f));
+}
+
+static float4 brInverseStereographic(float3 p, thread float &k) {
+ k = 2.0f / (1.0f + dot(p, p));
+ return float4(k * p, k - 1.0f);
+}
+
+static float brFTorus(float4 p4) {
+ float xyLen = max(length(p4.xy), 1.0e-5f);
+ float zwLen = max(length(p4.zw), 1.0e-5f);
+ float d1 = xyLen / zwLen - 1.0f;
+ float d2 = zwLen / xyLen - 1.0f;
+ float d = d1 < 0.0f ? -d1 : d2;
+ return d / BR_PI;
+}
+
+static float brFixDistance(float d, float k) {
+ float sn = sign(d);
+ d = abs(d);
+ d = d / max(k, 1.0e-5f) * 1.82f;
+ d += 1.0f;
+ d = pow(d, 0.5f);
+ d -= 1.0f;
+ d *= 5.0f / 3.0f;
+ return d * sn;
+}
+
+static float brMap(float3 p, float time) {
+ float k;
+ float4 p4 = brInverseStereographic(p, k);
+
+ float2 zy = p4.zy;
+ brRotate(zy, time * -BR_PI / 2.0f);
+ p4.z = zy.x;
+ p4.y = zy.y;
+
+ float2 xw = float2(p4.x, p4.w);
+ brRotate(xw, time * -BR_PI / 2.0f);
+ p4.x = xw.x;
+ p4.w = xw.y;
+
+ float d = brFTorus(p4);
+ d = abs(d);
+ d -= 0.2f;
+ d = brFixDistance(d, k);
+ d = brSmax(d, length(p) - 1.85f, 0.2f);
+ return d;
+}
+
+static float2 brBoxIntersect(float3 ro, float3 rd, float3 halfExt) {
+ float3 inv = 1.0f / rd;
+ float3 t0 = (-halfExt - ro) * inv;
+ float3 t1 = (halfExt - ro) * inv;
+ float3 tMin = min(t0, t1);
+ float3 tMax = max(t0, t1);
+ return float2(
+ max(max(tMin.x, tMin.y), tMin.z),
+ min(min(tMax.x, tMax.y), tMax.z));
+}
+
+fragment float4 bubbleRingsFragment(
+ BubbleRingsVertexOut in [[stage_in]],
+ constant BubbleRingsUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float cubeScale = max(uniforms.boxScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / cubeScale;
+ float3 hit = (in.worldPos - center) / cubeScale;
+ float3 rd = normalize(hit - eye);
+
+ bool insideBox = all(abs(eye) < BR_BOX_HALF - 1.0e-3f);
+ float2 tBox = brBoxIntersect(eye, rd, BR_BOX_HALF);
+ if (!insideBox && tBox.x > tBox.y) {
+ discard_fragment();
+ }
+
+ float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f);
+ float3 rayOrigin = (eye + rd * (tStart + BR_TRACE_EPSILON)) * BR_SCENE_SCALE;
+ float time = fmod(uniforms.time / 2.0f, 1.0f);
+
+ float rayLength = 0.0f;
+ float distance = 0.0f;
+ float3 color = float3(0.0f);
+
+ for (int step = 0; step < int(BR_ITER); ++step) {
+ rayLength += max(BR_INTERSECTION_PRECISION, abs(distance) * BR_FUDGE_FACTOR);
+ float3 rayPosition = rayOrigin + rd * rayLength;
+ distance = brMap(rayPosition, time);
+
+ float3 c = float3(max(0.0f, 0.01f - abs(distance)) * 0.5f);
+ c *= float3(1.4f, 2.1f, 1.7f);
+ c += float3(0.6f, 0.25f, 0.7f) * BR_FUDGE_FACTOR / 160.0f;
+ c *= 1.0f - smoothstep(7.0f, 20.0f, length(rayPosition));
+
+ float rl = 1.0f - smoothstep(0.1f, BR_MAX_DIST, rayLength);
+ c *= rl;
+ c *= brSpectrum(rl * 6.0f - 0.6f);
+
+ color += c;
+ if (rayLength > BR_MAX_DIST) {
+ break;
+ }
+ }
+
+ color = pow(max(color, 0.0f), float3(1.0f / 1.8f)) * 2.0f;
+ color = pow(max(color, 0.0f), float3(2.0f)) * 3.0f;
+ color = pow(max(color, 0.0f), float3(1.0f / 2.2f));
+
+ return float4(clamp(color, 0.0f, 1.0f), 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/BubbleRings/BubbleRingsTypes.swift b/vr-dive/Demos/BubbleRings/BubbleRingsTypes.swift
new file mode 100644
index 0000000..4ed3686
--- /dev/null
+++ b/vr-dive/Demos/BubbleRings/BubbleRingsTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct BubbleRingsUniforms in BubbleRingsShaders.metal.
+struct BubbleRingsUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var boxScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeRenderer.swift b/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeRenderer.swift
new file mode 100644
index 0000000..5afe319
--- /dev/null
+++ b/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeRenderer.swift
@@ -0,0 +1,170 @@
+import Metal
+import simd
+
+// CartoonFractalCubeRenderer.swift
+//
+// Original implementation for a cube-portal cartoon-styled fractal scene.
+// Visual inspiration requested from ShaderToy XsBXWt:
+// https://www.shadertoy.com/view/XsBXWt
+// This implementation is original and does not reuse source code from the
+// reference shader.
+
+final class CartoonFractalCubeRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .cartoonFractalCube
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ private let cubeScale: Float = 2.0
+ private let travelSpeed: Float = 0.075
+ private let objectCenter = SIMD3(0.0, -0.04, -1.75)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = CartoonFractalCubeRenderer.makeBox(device: device)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try CartoonFractalCubeRenderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = CartoonFractalCubeRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.back)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = CartoonFractalCubeUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ travelSpeed: travelSpeed,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension CartoonFractalCubeRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let x: Float = 1.0
+ let y: Float = 1.0
+ let z: Float = 1.0
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vBuf = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared)!
+ let iBuf = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared)!
+ return (vBuf, iBuf, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice, library: MTLLibrary, maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "cartoonFractalCubeVertex")
+ desc.fragmentFunction = library.makeFunction(name: "cartoonFractalCubeFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vd = MTLVertexDescriptor()
+ vd.attributes[0].format = .float3
+ vd.attributes[0].offset = 0
+ vd.attributes[0].bufferIndex = 0
+ vd.attributes[1].format = .float3
+ vd.attributes[1].offset = MemoryLayout>.stride
+ vd.attributes[1].bufferIndex = 0
+ vd.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vd
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeShaders.metal b/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeShaders.metal
new file mode 100644
index 0000000..ae4e284
--- /dev/null
+++ b/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeShaders.metal
@@ -0,0 +1,247 @@
+// CartoonFractalCubeShaders.metal
+//
+// Original cube-portal cartoon fractal scene with normal-based color and dark edges.
+// Visual inspiration requested from ShaderToy XsBXWt:
+// https://www.shadertoy.com/view/XsBXWt
+// This implementation is original and does not reuse source code from the
+// reference shader.
+
+#include
+using namespace metal;
+
+#define CFC_RAY_STEPS 150
+#define CFC_MAX_DIST 25.0f
+#define CFC_DETAIL_EPS 0.001f
+#define CFC_SCENE_SCALE 12.0f
+
+struct CartoonFractalCubeUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float travelSpeed;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct CartoonFractalCubeVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+struct CfcMapSample {
+ float dist;
+ float edgeShape;
+};
+
+vertex CartoonFractalCubeVertexOut cartoonFractalCubeVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant CartoonFractalCubeUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ CartoonFractalCubeVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float2 cfc_rot(float2 p, float a) {
+ float c = cos(a);
+ float s = sin(a);
+ return float2(c * p.x - s * p.y, s * p.x + c * p.y);
+}
+
+static float cfc_mod(float x, float y) {
+ return x - y * floor(x / y);
+}
+
+static float4 cfc_formula(float4 p) {
+ p.xz = abs(p.xz + 1.0f) - abs(p.xz - 1.0f) - p.xz;
+ p.y -= 0.25f;
+ p.xy = cfc_rot(p.xy, 0.61086524f);
+ float denom = clamp(dot(p.xyz, p.xyz), 0.2f, 1.0f);
+ p *= 2.0f / denom;
+ return p;
+}
+
+static CfcMapSample cfc_map(float3 pos, float time) {
+ pos.y += sin(pos.z - time * 0.6f) * 0.15f;
+
+ float3 tpos = pos;
+ tpos.z = abs(3.0f - cfc_mod(tpos.z, 6.0f));
+ float4 p = float4(tpos, 1.0f);
+ float edgeShape = 0.0f;
+ for (int i = 0; i < 4; ++i) {
+ p = cfc_formula(p);
+ edgeShape += exp(-1.4f * abs(p.y));
+ }
+
+ float fractal = (length(max(float2(0.0f), p.yz - 1.5f)) - 1.0f) / p.w;
+
+ float ribs = max(abs(pos.x + 1.0f) - 0.3f, pos.y - 0.35f);
+ ribs = max(ribs, -max(abs(pos.x + 1.0f) - 0.1f, pos.y - 0.5f));
+
+ float stripes = abs(0.25f - cfc_mod(pos.z, 0.5f));
+ ribs = max(ribs, -max(abs(stripes) - 0.2f, pos.y - 0.3f));
+ ribs = max(ribs, -max(abs(stripes) - 0.01f, -pos.y + 0.32f));
+
+ CfcMapSample sample;
+ sample.dist = min(fractal, ribs);
+ sample.edgeShape = edgeShape;
+ return sample;
+}
+
+static float cfc_de(float3 pos, float time) {
+ return cfc_map(pos, time).dist;
+}
+
+static float3 cfc_normal(float3 p, float time) {
+ float e = CFC_DETAIL_EPS * 4.0f;
+ float dx = cfc_de(p + float3(e, 0, 0), time) - cfc_de(p - float3(e, 0, 0), time);
+ float dy = cfc_de(p + float3(0, e, 0), time) - cfc_de(p - float3(0, e, 0), time);
+ float dz = cfc_de(p + float3(0, 0, e), time) - cfc_de(p - float3(0, 0, e), time);
+ return normalize(float3(dx, dy, dz));
+}
+
+static float cfc_edgeMetric(float3 p, float time, float det) {
+ float3 e = float3(0.0f, det * 5.0f, 0.0f);
+ float d1 = cfc_de(p - e.yxx, time);
+ float d2 = cfc_de(p + e.yxx, time);
+ float d3 = cfc_de(p - e.xyx, time);
+ float d4 = cfc_de(p + e.xyx, time);
+ float d5 = cfc_de(p - e.xxy, time);
+ float d6 = cfc_de(p + e.xxy, time);
+ float d = cfc_de(p, time);
+ float edge = abs(d - 0.5f * (d2 + d1))
+ + abs(d - 0.5f * (d4 + d3))
+ + abs(d - 0.5f * (d6 + d5));
+ return min(1.0f, pow(edge, 0.55f) * 15.0f);
+}
+
+static float3 cfc_path(float time) {
+ float ti = time * 1.5f;
+ return float3(
+ sin(ti),
+ (1.0f - sin(ti * 2.0f)) * 0.5f,
+ ti * 5.0f) * 0.5f;
+}
+
+static void cfc_applyPath(thread float3 &ro, thread float3 &rd, float time) {
+ float3 go = cfc_path(time);
+ float3 adv = cfc_path(time + 0.7f);
+ float3 advec = normalize(adv - go);
+
+ float an = adv.x - go.x;
+ an *= min(1.0f, abs(adv.z - go.z)) * sign(adv.z - go.z) * 0.7f;
+ rd.xy = cfc_rot(rd.xy, an);
+
+ an = advec.y * 1.7f;
+ rd.yz = cfc_rot(rd.yz, an);
+
+ an = atan2(advec.x, advec.z);
+ rd.xz = cfc_rot(rd.xz, an);
+
+ ro += float3(-1.0f, 0.7f, 0.0f) + go;
+}
+
+static float3 cfc_sky(float3 rd, float time) {
+ float3 skyDir = rd;
+ skyDir.y -= 0.02f;
+ float sunSize = 6.2f;
+ float angle = atan2(skyDir.x, skyDir.y) + time * 1.5f;
+ float spoke = abs(0.2f - cfc_mod(angle, 0.4f));
+ float radial = length(skyDir.xy);
+ float sun = pow(clamp(1.0f - radial * sunSize - spoke, 0.0f, 1.0f), 0.1f);
+ float sunBorder = pow(clamp(1.0f - radial * (sunSize - 0.2f) - spoke, 0.0f, 1.0f), 0.1f);
+ float rays = pow(clamp(1.0f - radial * (sunSize - 4.5f) - 0.5f * spoke, 0.0f, 1.0f), 3.0f);
+ float y = mix(0.45f, 1.2f, pow(smoothstep(0.0f, 1.0f, 0.75f - skyDir.y), 2.0f)) * (1.0f - sunBorder * 0.5f);
+
+ float3 backg = float3(0.5f, 0.0f, 1.0f)
+ * ((1.0f - sun) * (1.0f - rays) * y + (1.0f - sunBorder) * rays * float3(1.0f, 0.8f, 0.15f) * 3.0f);
+ backg += float3(1.0f, 0.9f, 0.1f) * sun;
+ backg = max(backg, rays * float3(1.0f, 0.9f, 0.5f));
+ return backg;
+}
+
+static bool cfc_boxHit(
+ float3 ro, float3 rd, float3 bmin, float3 bmax,
+ thread float &tNear, thread float &tFar)
+{
+ float3 t0 = (bmin - ro) / rd;
+ float3 t1 = (bmax - ro) / rd;
+ float3 lo = min(t0, t1);
+ float3 hi = max(t0, t1);
+ tNear = max(max(lo.x, lo.y), lo.z);
+ tFar = min(min(hi.x, hi.y), hi.z);
+ return tFar >= max(tNear, 0.0f);
+}
+
+fragment float4 cartoonFractalCubeFragment(
+ CartoonFractalCubeVertexOut in [[stage_in]],
+ constant CartoonFractalCubeUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *v2wMats [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = v2wMats[vi];
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 roLocal = (camWorld - center) / uniforms.cubeScale;
+ float3 rdLocal = normalize(in.worldPos - camWorld);
+
+ float tEntry;
+ float tExit;
+ if (!cfc_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) {
+ discard_fragment();
+ }
+
+ float time = uniforms.time * uniforms.travelSpeed * 0.5f;
+ float3 ro = (roLocal + rdLocal * max(tEntry, 0.0f)) * CFC_SCENE_SCALE;
+ float3 rd = rdLocal;
+ cfc_applyPath(ro, rd, time);
+
+ float dist = 0.0f;
+ float d = 100.0f;
+ float det = CFC_DETAIL_EPS;
+ float3 p = ro;
+ bool hit = false;
+ for (int i = 0; i < CFC_RAY_STEPS; ++i) {
+ if (d <= det || dist >= CFC_MAX_DIST) { break; }
+ p = ro + dist * rd;
+ d = cfc_de(p, time);
+ det = CFC_DETAIL_EPS * exp(0.13f * dist);
+ dist += d;
+ if (d <= det) { hit = true; }
+ }
+
+ float3 sky = cfc_sky(rd, time);
+ float3 color = sky;
+ if (hit && dist < CFC_MAX_DIST) {
+ p -= (det - d) * rd;
+ float3 norm = cfc_normal(p, time);
+ float edge = cfc_edgeMetric(p, time, det);
+ float3 base = (1.0f - abs(norm)) * max(0.0f, 1.0f - edge * 0.8f);
+ float3 warm = float3(1.0f, 0.9f, 0.3f);
+ float fade = exp(-0.004f * dist * dist);
+ color = mix(warm, base, fade);
+ color = mix(color, sky, smoothstep(18.0f, CFC_MAX_DIST, dist));
+ color *= float3(1.0f, 0.9f, 0.85f);
+ }
+
+ color = pow(max(color, 0.0f), float3(1.4f)) * 1.2f;
+ float luminance = dot(color, float3(0.299f, 0.587f, 0.114f));
+ color = mix(float3(luminance), color, 0.65f);
+ color = clamp(color, 0.0f, 1.0f);
+ return float4(color, 1.0f);
+}
\ No newline at end of file
diff --git a/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeTypes.swift b/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeTypes.swift
new file mode 100644
index 0000000..5e9e18f
--- /dev/null
+++ b/vr-dive/Demos/CartoonFractalCube/CartoonFractalCubeTypes.swift
@@ -0,0 +1,11 @@
+import simd
+
+/// Must stay in sync with the Metal struct CartoonFractalCubeUniforms in
+/// CartoonFractalCubeShaders.metal.
+struct CartoonFractalCubeUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var travelSpeed: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/CloudyCrystal/CloudyCrystalRenderer.swift b/vr-dive/Demos/CloudyCrystal/CloudyCrystalRenderer.swift
new file mode 100644
index 0000000..e833610
--- /dev/null
+++ b/vr-dive/Demos/CloudyCrystal/CloudyCrystalRenderer.swift
@@ -0,0 +1,170 @@
+import Metal
+import simd
+
+// CloudyCrystalRenderer.swift
+// "Cloudy crystal" — cube-portal adaptation of Shadertoy "fdlSDl"
+// Original: https://www.shadertoy.com/view/fdlSDl
+
+final class CloudyCrystalRenderer: VisualPatternController {
+ let identifier: VisualPatternKind = .cloudyCrystal
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // 2 m cube: mesh half-extents 1.0 × cubeScale 1.0 = 1 m half-extents in world space.
+ private let cubeScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -2.1)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = CloudyCrystalRenderer.makeBox(
+ device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try CloudyCrystalRenderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = CloudyCrystalRenderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = CloudyCrystalUniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension CloudyCrystalRenderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared
+ )!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared
+ )!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "cloudyCrystalVertex")
+ desc.fragmentFunction = library.makeFunction(name: "cloudyCrystalFragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/CloudyCrystal/CloudyCrystalShaders.metal b/vr-dive/Demos/CloudyCrystal/CloudyCrystalShaders.metal
new file mode 100644
index 0000000..12a5c68
--- /dev/null
+++ b/vr-dive/Demos/CloudyCrystal/CloudyCrystalShaders.metal
@@ -0,0 +1,292 @@
+// CloudyCrystalShaders.metal
+// "Cloudy crystal" — cube-portal adaptation of Shadertoy "fdlSDl"
+// Original: https://www.shadertoy.com/view/fdlSDl
+// License in source: CC0
+//
+// Metal adaptation notes:
+// - The original GLSL is a screen-space effect with its own synthetic camera.
+// - This version replaces that camera with the real per-eye world ray coming
+// from the cube surface (or the viewer position when inside the cube).
+// - The crystal scene is fixed in scene space around the cube centre, so user
+// head motion produces stereo parallax and positional motion instead of a
+// flat 2D image on the cube wall.
+// - Unlike the original post-process, this version intentionally omits the
+// screen-space vignette because it would reintroduce a 2D overlay artifact.
+
+#include
+using namespace metal;
+
+struct CloudyCrystalUniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct CloudyCrystalVertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float CC_PI = 3.141592654f;
+static constant float CC_MISS = 1.0e4f;
+static constant float CC_REFRACT_INDEX = 0.85f;
+static constant float3 CC_LIGHT_POS = 2.0f * float3(1.5f, 2.0f, 1.0f);
+static constant float3 CC_SUN_COL = float3(8.0f, 7.0f, 6.0f) / 8.0f;
+
+vertex CloudyCrystalVertexOut cloudyCrystalVertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant CloudyCrystalUniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ CloudyCrystalVertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float ccTanhApprox(float x) {
+ float x2 = x * x;
+ return clamp(x * (27.0f + x2) / (27.0f + 9.0f * x2), -1.0f, 1.0f);
+}
+
+static float3 ccHsv2Rgb(float3 c) {
+ const float4 K = float4(1.0f, 2.0f / 3.0f, 1.0f / 3.0f, 3.0f);
+ float3 p = abs(fract(c.xxx + K.xyz) * 6.0f - K.www);
+ return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0f, 1.0f), c.y);
+}
+
+static float ccL2(float3 x) {
+ return dot(x, x);
+}
+
+// IQ quartic sphere intersector translated directly from GLSL.
+static float ccRaySphere4(float3 ro, float3 rd, float ra) {
+ float r2 = ra * ra;
+ float3 d2 = rd * rd;
+ float3 d3 = d2 * rd;
+ float3 o2 = ro * ro;
+ float3 o3 = o2 * ro;
+ float ka = 1.0f / dot(d2, d2);
+ float k3 = ka * dot(ro, d3);
+ float k2 = ka * dot(o2, d2);
+ float k1 = ka * dot(o3, rd);
+ float k0 = ka * (dot(o2, o2) - r2 * r2);
+ float c2 = k2 - k3 * k3;
+ float c1 = k1 + 2.0f * k3 * k3 * k3 - 3.0f * k3 * k2;
+ float c0 = k0 - 3.0f * k3 * k3 * k3 * k3 + 6.0f * k3 * k3 * k2 - 4.0f * k3 * k1;
+ float p = c2 * c2 + c0 / 3.0f;
+ float q = c2 * c2 * c2 - c2 * c0 + c1 * c1;
+ float h = q * q - p * p * p;
+ if (h < 0.0f) {
+ return CC_MISS;
+ }
+ float sh = sqrt(h);
+ float s = sign(q + sh) * pow(abs(q + sh), 1.0f / 3.0f);
+ float t = sign(q - sh) * pow(abs(q - sh), 1.0f / 3.0f);
+ float2 w = float2(s + t, s - t);
+ float2 v = float2(w.x + c2 * 4.0f, w.y * sqrt(3.0f)) * 0.5f;
+ float r = length(v);
+ return -abs(v.y) / sqrt(max(r + v.x, 1.0e-6f)) - c1 / max(r, 1.0e-6f) - k3;
+}
+
+static float3 ccSphere4Normal(float3 pos) {
+ return normalize(pos * pos * pos);
+}
+
+static float ccIRaySphere4(float3 ro, float3 rd, float ra) {
+ float3 rro = ro + rd * ra * 4.0f;
+ float3 rrd = -rd;
+ float rt = ccRaySphere4(rro, rrd, ra);
+ if (rt == CC_MISS) {
+ return CC_MISS;
+ }
+ float3 rpos = rro + rrd * rt;
+ return length(rpos - ro);
+}
+
+static float ccRayPlane(float3 ro, float3 rd, float4 p) {
+ return -(dot(ro, p.xyz) + p.w) / dot(rd, p.xyz);
+}
+
+static float2 ccCSqr(float2 a) {
+ return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y);
+}
+
+static float3x3 ccRotX(float a) {
+ float s = sin(a);
+ float c = cos(a);
+ return float3x3(float3(1.0f, 0.0f, 0.0f), float3(0.0f, c, -s), float3(0.0f, s, c));
+}
+
+static float3x3 ccRotY(float a) {
+ float s = sin(a);
+ float c = cos(a);
+ return float3x3(float3(c, 0.0f, s), float3(0.0f, 1.0f, 0.0f), float3(-s, 0.0f, c));
+}
+
+static float ccMarbleDf(float3 p) {
+ float res = 0.0f;
+ float3 c = p;
+ float scale = 0.72f;
+ constexpr int maxIter = 10;
+ for (int i = 0; i < maxIter; ++i) {
+ p = scale * abs(p) / dot(p, p) - scale;
+ p.yz = ccCSqr(p.yz);
+ p = p.zxy;
+ res += exp(-19.0f * abs(dot(p, c)));
+ }
+ return res;
+}
+
+static float3 ccMarbleMarch(float3 ro, float3 rd, float2 tminmax) {
+ float t = tminmax.x;
+ float dt = 0.02f;
+ float3 col = float3(0.0f);
+ float c = 0.0f;
+ constexpr int maxIter = 64;
+ for (int i = 0; i < maxIter; ++i) {
+ t += dt * exp(-2.0f * c);
+ if (t > tminmax.y) {
+ break;
+ }
+ float3 pos = ro + t * rd;
+ c = ccMarbleDf(pos);
+ c *= 0.5f;
+ float dist = abs(pos.x + pos.y - 0.15f) * 10.0f;
+ float3 dcol = float3(c * c * c - c * dist, c * c - c, c);
+ col += dcol;
+ }
+ float scale = 0.005f;
+ float td = (t - tminmax.x) / max(tminmax.y - tminmax.x, 1.0e-4f);
+ col *= exp(-10.0f * td);
+ col *= scale;
+ return col;
+}
+
+static float3 ccSkyColor(float3 ro, float3 rd) {
+ const float3 skyCol1 = pow(float3(0.2f, 0.4f, 0.6f), float3(0.25f));
+ const float3 skyCol2 = pow(float3(0.4f, 0.7f, 1.0f), float3(2.0f));
+ float3 sunDir = normalize(CC_LIGHT_POS);
+ float sunDot = max(dot(rd, sunDir), 0.0f);
+ float3 final = float3(0.0f);
+
+ final += mix(skyCol1, skyCol2, rd.y);
+ final += 0.5f * CC_SUN_COL * pow(sunDot, 20.0f);
+ final += 4.0f * CC_SUN_COL * pow(sunDot, 400.0f);
+
+ float tp = ccRayPlane(ro, rd, float4(float3(0.0f, 1.0f, 0.0f), 0.505f));
+ if (tp > 0.0f) {
+ float3 pos = ro + tp * rd;
+ float3 ld = normalize(CC_LIGHT_POS - pos);
+ float ts4 = ccRaySphere4(pos, ld, 0.5f);
+ float3 spos = pos + ld * ts4;
+ float its4 = ccIRaySphere4(spos, ld, 0.5f);
+ float sha = ts4 == CC_MISS ? 1.0f : (1.0f - ccTanhApprox(its4 * 1.5f / (0.5f + 0.5f * ts4)));
+ float3 nor = float3(0.0f, 1.0f, 0.0f);
+ float3 icol = 1.5f * skyCol1 + 4.0f * CC_SUN_COL * sha * dot(-rd, nor);
+ float2 ppos = pos.xz * 0.75f;
+ ppos = fract(ppos + 0.5f) - 0.5f;
+ float pd = min(abs(ppos.x), abs(ppos.y));
+ float3 pcol = mix(float3(0.4f), float3(0.3f), exp(-60.0f * pd));
+
+ float3 col = clamp(icol * pcol, 0.0f, 1.25f);
+ float f = exp(-10.0f * (max(tp - 10.0f, 0.0f) / 100.0f));
+ return mix(final, col, f);
+ }
+ return final;
+}
+
+static float3 ccRender1(float3 ro, float3 rd) {
+ float its4 = ccIRaySphere4(ro, rd, 0.5f);
+ return ccMarbleMarch(ro, rd, float2(0.0f, its4));
+}
+
+static float3 ccRender(float3 ro, float3 rd) {
+ float3 skyCol = ccSkyColor(ro, rd);
+ float ts4 = ccRaySphere4(ro, rd, 0.5f);
+ if (ts4 >= CC_MISS) {
+ return skyCol;
+ }
+
+ float3 pos = ro + ts4 * rd;
+ float3 nor = ccSphere4Normal(pos);
+ float3 refr = refract(rd, nor, CC_REFRACT_INDEX);
+ float3 refl = reflect(rd, nor);
+ float3 rcol = ccSkyColor(pos, refl);
+ float fre = mix(0.0f, 1.0f, pow(1.0f - dot(-rd, nor), 4.0f));
+
+ float3 lv = CC_LIGHT_POS - pos;
+ float ll2 = ccL2(lv);
+ float ll = sqrt(ll2);
+ float3 ld = lv / ll;
+
+ float dm = min(1.0f, 40.0f / ll2);
+ float dif = pow(max(dot(nor, ld), 0.0f), 8.0f) * dm;
+ float spe = pow(max(dot(reflect(-ld, nor), -rd), 0.0f), 100.0f);
+ float lin = mix(0.0f, 1.0f, dif);
+ float3 lcol = 2.0f * sqrt(CC_SUN_COL);
+
+ float3 col = ccRender1(pos, refr);
+ float3 diff = ccHsv2Rgb(float3(0.7f, fre, 0.075f * lin)) * lcol;
+ col += fre * rcol + diff + spe * lcol;
+ if (all(refr == float3(0.0f))) {
+ col = float3(1.0f, 0.0f, 0.0f);
+ }
+ return col;
+}
+
+// The cube itself is only the portal. The crystal scene is centred at the
+// cube origin and is not clipped to the cube bounds.
+fragment float4 cloudyCrystalFragment(
+ CloudyCrystalVertexOut in [[stage_in]],
+ constant CloudyCrystalUniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+
+ // Scene space is cube-centred. cubeScale = 1.0, but keep the transform
+ // explicit so the renderer remains consistent with other cube portal demos.
+ float sceneScale = max(uniforms.cubeScale, 1.0e-4f);
+ float3 roScene = (camWorld - center) / sceneScale;
+ float3 rdScene = normalize(in.worldPos - camWorld);
+ float3 surfaceScene = (in.worldPos - center) / sceneScale;
+
+ bool insideBox = all(abs(roScene) < float3(0.999f));
+ float3 marchOrigin = insideBox ? (roScene + rdScene * 0.002f) : (surfaceScene + rdScene * 0.002f);
+
+ // Replace the original screen-space orbit camera with a scene that is fixed
+ // in world space. A mild local-space rotation preserves the source's motion
+ // without tying image structure to the viewer.
+ float3x3 sceneRot = ccRotY(CC_PI * 0.5f + sin(uniforms.time * 0.05f))
+ * ccRotX(0.5f + 0.125f * sin(uniforms.time * 0.05f * sqrt(0.5f)));
+ float3 localRo = sceneRot * marchOrigin;
+ float3 localRd = normalize(sceneRot * rdScene);
+
+ float3 col = ccRender(localRo, localRd);
+
+ // Post-process from the source, minus the screen-space vignette.
+ col = clamp(col, 0.0f, 1.0f);
+ col = pow(col, float3(1.0f / 2.2f));
+ col = col * 0.6f + 0.4f * col * col * (3.0f - 2.0f * col);
+ col = mix(col, float3(dot(col, float3(0.33f))), -0.4f);
+
+ return float4(col, 1.0f);
+}
diff --git a/vr-dive/Demos/CloudyCrystal/CloudyCrystalTypes.swift b/vr-dive/Demos/CloudyCrystal/CloudyCrystalTypes.swift
new file mode 100644
index 0000000..c1f1ce4
--- /dev/null
+++ b/vr-dive/Demos/CloudyCrystal/CloudyCrystalTypes.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct CloudyCrystalUniforms in CloudyCrystalShaders.metal.
+struct CloudyCrystalUniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Renderer.swift b/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Renderer.swift
new file mode 100644
index 0000000..8960998
--- /dev/null
+++ b/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Renderer.swift
@@ -0,0 +1,171 @@
+import Metal
+import simd
+
+// CrystalCubeLatticinioCore1Renderer.swift
+// "Crystal Cube Latticinio core 1" — cube-portal adaptation of Shadertoy "Wfy3Wm"
+// Original: https://www.shadertoy.com/view/Wfy3Wm
+
+final class CrystalCubeLatticinioCore1Renderer: VisualPatternController {
+ let identifier: VisualPatternKind = .crystalCubeLatticinioCore1
+ let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
+
+ private let pipelineState: MTLRenderPipelineState
+ private let depthStencilState: MTLDepthStencilState
+ private let vertexBuffer: MTLBuffer
+ private let indexBuffer: MTLBuffer
+ private let indexCount: Int
+ private let maxViewCount: Int
+
+ // 2 m cube: mesh half-extents 1.0 × cubeScale 1.0 = 1 m half-extents in world space.
+ private let cubeScale: Float = 1.0
+ private let objectCenter = SIMD3(0.0, 0.0, -2.1)
+
+ private var animationTime: Float = 0
+ private var lastSimulationTime: Float?
+
+ init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws {
+ self.maxViewCount = max(1, maxViewCount)
+
+ let geo = CrystalCubeLatticinioCore1Renderer.makeBox(
+ device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01)
+ vertexBuffer = geo.vertexBuffer
+ indexBuffer = geo.indexBuffer
+ indexCount = geo.indexCount
+
+ pipelineState = try CrystalCubeLatticinioCore1Renderer.makePipelineState(
+ device: device, library: library, maxViewCount: self.maxViewCount)
+ depthStencilState = CrystalCubeLatticinioCore1Renderer.makeDepthStencilState(device: device)
+ }
+
+ func updateSimulation(_ context: PatternSimulationContext) {
+ defer { lastSimulationTime = context.time }
+ guard let lastSimulationTime else { return }
+ let deltaTime = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0))
+ animationTime += deltaTime * max(context.speedMultiplier, 0)
+ }
+
+ func resetToInitialState() {
+ animationTime = 0
+ lastSimulationTime = nil
+ }
+
+ func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) {
+ encoder.setRenderPipelineState(pipelineState)
+ encoder.setDepthStencilState(depthStencilState)
+ encoder.setCullMode(.none)
+ context.applyViewConfiguration(on: encoder)
+
+ encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
+
+ var uniforms = CrystalCubeLatticinioCore1Uniforms(
+ time: animationTime,
+ viewCount: UInt32(context.viewData.viewCount),
+ cubeScale: cubeScale,
+ padding: 0,
+ objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0))
+
+ encoder.setVertexBytes(
+ &uniforms, length: MemoryLayout.stride, index: 1)
+
+ var vpMatrices = context.viewData.viewProjectionMatrices
+ if vpMatrices.isEmpty { vpMatrices = [matrix_identity_float4x4] }
+ vpMatrices.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setVertexBytes(base, length: $0.count, index: 2)
+ }
+ }
+
+ encoder.setFragmentBytes(
+ &uniforms, length: MemoryLayout.stride, index: 0)
+
+ var viewToWorld = context.viewData.viewToWorldTransforms
+ if viewToWorld.isEmpty { viewToWorld = [matrix_identity_float4x4] }
+ viewToWorld.withUnsafeBytes {
+ if let base = $0.baseAddress, $0.count > 0 {
+ encoder.setFragmentBytes(base, length: $0.count, index: 1)
+ }
+ }
+
+ encoder.drawIndexedPrimitives(
+ type: .triangle,
+ indexCount: indexCount,
+ indexType: .uint16,
+ indexBuffer: indexBuffer,
+ indexBufferOffset: 0)
+ }
+}
+
+extension CrystalCubeLatticinioCore1Renderer {
+ fileprivate static func makeBox(
+ device: MTLDevice,
+ localHalfExtents e: SIMD3
+ ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) {
+ typealias V = MeshVertex
+ let (x, y, z) = (e.x, e.y, e.z)
+ let faces: [(positions: [SIMD3], normal: SIMD3)] = [
+ ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]),
+ ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]),
+ ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]),
+ ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]),
+ ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]),
+ ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]),
+ ]
+
+ var vertices: [V] = []
+ vertices.reserveCapacity(24)
+ var indices: [UInt16] = []
+ indices.reserveCapacity(36)
+
+ for face in faces {
+ let base = UInt16(vertices.count)
+ for position in face.positions {
+ vertices.append(V(position: position, normal: face.normal))
+ }
+ indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3])
+ }
+
+ let vertexBuffer = device.makeBuffer(
+ bytes: vertices,
+ length: MemoryLayout.stride * vertices.count,
+ options: .storageModeShared
+ )!
+ let indexBuffer = device.makeBuffer(
+ bytes: indices,
+ length: MemoryLayout.stride * indices.count,
+ options: .storageModeShared
+ )!
+ return (vertexBuffer, indexBuffer, indices.count)
+ }
+
+ fileprivate static func makePipelineState(
+ device: MTLDevice,
+ library: MTLLibrary,
+ maxViewCount: Int
+ ) throws -> MTLRenderPipelineState {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "crystalCubeLatticinioCore1Vertex")
+ desc.fragmentFunction = library.makeFunction(name: "crystalCubeLatticinioCore1Fragment")
+ desc.colorAttachments[0].pixelFormat = .rgba16Float
+ desc.depthAttachmentPixelFormat = .depth32Float
+
+ let vertexDescriptor = MTLVertexDescriptor()
+ vertexDescriptor.attributes[0].format = .float3
+ vertexDescriptor.attributes[0].offset = 0
+ vertexDescriptor.attributes[0].bufferIndex = 0
+ vertexDescriptor.attributes[1].format = .float3
+ vertexDescriptor.attributes[1].offset = MemoryLayout>.stride
+ vertexDescriptor.attributes[1].bufferIndex = 0
+ vertexDescriptor.layouts[0].stride = MemoryLayout.stride
+ desc.vertexDescriptor = vertexDescriptor
+
+ desc.maxVertexAmplificationCount = max(maxViewCount, 1)
+ return try device.makeRenderPipelineState(descriptor: desc)
+ }
+
+ fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState {
+ let desc = MTLDepthStencilDescriptor()
+ desc.depthCompareFunction = .greater
+ desc.isDepthWriteEnabled = true
+ return device.makeDepthStencilState(descriptor: desc)!
+ }
+}
diff --git a/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Shaders.metal b/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Shaders.metal
new file mode 100644
index 0000000..b37fc73
--- /dev/null
+++ b/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Shaders.metal
@@ -0,0 +1,568 @@
+// CrystalCubeLatticinioCore1Shaders.metal
+// "Crystal Cube Latticinio core 1" — cube-portal adaptation of Shadertoy "Wfy3Wm"
+// Original: https://www.shadertoy.com/view/Wfy3Wm
+//
+// Metal adaptation notes:
+// - The original GLSL already renders a glass cube with internal bilinear patch
+// geometry, but it does so from a synthetic screen-space orbit camera.
+// - This version replaces that camera with the real per-eye world ray from the
+// application. Outside the cube, rendering starts from the visible container
+// surface. Inside the cube, rendering uses the currently viewed inner face as
+// the portal and marches inward from that face.
+// - The internal crystal lattice field remains fixed in scene space and is not
+// clipped to the container bounds, so user motion produces true stereo and
+// positional parallax rather than a 2D surface image.
+
+#include
+using namespace metal;
+
+struct CrystalCubeLatticinioCore1Uniforms {
+ float time;
+ uint viewCount;
+ float cubeScale;
+ float padding;
+ float4 objectCenter;
+};
+
+struct MeshVertex {
+ float3 position;
+ float3 normal;
+};
+
+struct CrystalCubeLatticinioCore1VertexOut {
+ float4 clipPos [[position]];
+ float3 worldPos;
+ uint viewIndex [[flat]];
+};
+
+static constant float3 CCLC_BOX_DIMS = float3(1.0f, 1.0f, 1.0f);
+static constant float CCLC_PI = 3.1415926f;
+static constant float CCLC_IOR = 1.33f;
+static constant float CCLC_HUE_SHIFT = 0.0f;
+static constant float CCLC_INTERIOR_SCENE_SCALE = 1.22f;
+static constant float CCLC_COLOR_FREQ = 2.5f;
+static constant float CCLC_COLOR_SPEED = 0.5f;
+static constant float CCLC_COLOR_WARP = 0.0f;
+static constant float CCLC_CONTRAST = 1.2f;
+
+vertex CrystalCubeLatticinioCore1VertexOut crystalCubeLatticinioCore1Vertex(
+ ushort amplificationID [[amplification_id]],
+ const device MeshVertex *vertices [[buffer(0)]],
+ constant CrystalCubeLatticinioCore1Uniforms &uniforms [[buffer(1)]],
+ constant float4x4 *vpMatrices [[buffer(2)]],
+ uint vertexID [[vertex_id]])
+{
+ MeshVertex vtx = vertices[vertexID];
+ uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u);
+ float3 worldPos = vtx.position * uniforms.cubeScale + uniforms.objectCenter.xyz;
+
+ CrystalCubeLatticinioCore1VertexOut out;
+ out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f);
+ out.worldPos = worldPos;
+ out.viewIndex = viewIndex;
+ return out;
+}
+
+static float3 cclcRotXRow(float3 v, float a) {
+ float s = sin(a);
+ float c = cos(a);
+ return float3(v.x, c * v.y + s * v.z, -s * v.y + c * v.z);
+}
+
+static float3 cclcRotYRow(float3 v, float a) {
+ float s = sin(a);
+ float c = cos(a);
+ return float3(c * v.x - s * v.z, v.y, s * v.x + c * v.z);
+}
+
+static float3 cclcRotZRow(float3 v, float a) {
+ float s = sin(a);
+ float c = cos(a);
+ return float3(c * v.x - s * v.y, s * v.x + c * v.y, v.z);
+}
+
+static float3 cclcPalette(float t) {
+ float3 a = float3(0.5f, 0.5f, 0.5f);
+ float3 b = float3(0.5f, 0.5f, 0.5f);
+ float3 c = float3(1.0f, 1.0f, 1.0f);
+ float3 d = float3(0.263f, 0.416f, 0.557f) + CCLC_HUE_SHIFT;
+ return a + b * cos(6.28318f * (c * t + d));
+}
+
+static float3 cclcGetColor(float3 p, float time) {
+ p = abs(p);
+
+ float warp = sin(p.x * 3.0f + time * CCLC_COLOR_SPEED)
+ * cos(p.y * 3.0f - time * CCLC_COLOR_SPEED);
+ p += CCLC_COLOR_WARP * warp;
+
+ float t = length(p) * CCLC_COLOR_FREQ;
+ t += 0.5f * sin(p.x * 5.0f + time * CCLC_COLOR_SPEED);
+ t += 0.3f * cos(p.y * 7.0f - time * CCLC_COLOR_SPEED);
+
+ float3 col = cclcPalette(t);
+ col *= 0.8f + 0.4f * sin(10.0f * t);
+ col = pow(clamp(col, 0.0f, 1.0f), float3(CCLC_CONTRAST));
+ return clamp(col, 0.0f, 1.0f);
+}
+
+static void cclcCalcColor(
+ float3 ro,
+ float3 rd,
+ float3 nor,
+ float d,
+ float len,
+ int idx,
+ bool si,
+ float td,
+ float time,
+ thread float4 &colx,
+ thread float4 &colsi)
+{
+ float3 pos = ro + rd * d;
+ float a = 1.0f - smoothstep(len - 0.15f * 0.5f, len + 0.00001f, length(pos));
+ float3 col = cclcGetColor(pos, time);
+ colx = float4(col, a);
+ if (si) {
+ pos = ro + rd * td;
+ float ta = 1.0f - smoothstep(len - 0.15f * 0.5f, len + 0.00001f, length(pos));
+ col = cclcGetColor(pos, time);
+ colsi = float4(col, ta);
+ }
+}
+
+static bool cclcIBilinearPatch(
+ float3 ro,
+ float3 rd,
+ float4 ps,
+ float4 ph,
+ float sz,
+ thread float &t,
+ thread float3 &norm,
+ thread bool &si,
+ thread float &tsi,
+ thread float3 &normsi,
+ thread float &fade,
+ thread float &fadesi)
+{
+ float3 va = float3(0.0f, 0.0f, ph.x + ph.w - ph.y - ph.z);
+ float3 vb = float3(0.0f, ps.w - ps.y, ph.z - ph.x);
+ float3 vc = float3(ps.z - ps.x, 0.0f, ph.y - ph.x);
+ float3 vd = float3(ps.x, ps.y, ph.x);
+ t = -1.0f;
+ tsi = -1.0f;
+ si = false;
+ fade = 1.0f;
+ fadesi = 1.0f;
+ norm = float3(0.0f, 1.0f, 0.0f);
+ normsi = float3(0.0f, 1.0f, 0.0f);
+
+ float tmp = 1.0f / (vb.y * vc.x);
+ float a = 0.0f;
+ float b = 0.0f;
+ float c = 0.0f;
+ float d = va.z * tmp;
+ float e = 0.0f;
+ float f = 0.0f;
+ float g = (vc.z * vb.y - vd.y * va.z) * tmp;
+ float h = (vb.z * vc.x - va.z * vd.x) * tmp;
+ float i = -1.0f;
+ float j = (vd.x * vd.y * va.z + vd.z * vb.y * vc.x) * tmp
+ - (vd.y * vb.z * vc.x + vd.x * vc.z * vb.y) * tmp;
+
+ float p = dot(float3(a, b, c), rd.xzy * rd.xzy) + dot(float3(d, e, f), rd.xzy * rd.zyx);
+ float q = dot(2.0f * ro.xzy * rd.xyz, float3(a, b, c))
+ + dot(ro.xzz * rd.zxy, float3(d, d, e))
+ + dot(ro.yyx * rd.zxy, float3(e, f, f))
+ + dot(float3(g, h, i), rd.xzy);
+ float r = dot(float3(a, b, c), ro.xzy * ro.xzy)
+ + dot(float3(d, e, f), ro.xzy * ro.zyx)
+ + dot(float3(g, h, i), ro.xzy)
+ + j;
+
+ if (abs(p) < 0.000001f) {
+ float tt = -r / q;
+ if (tt <= 0.0f) {
+ return false;
+ }
+ t = tt;
+ float3 pos = ro + t * rd;
+ if (length(pos) > sz) {
+ return false;
+ }
+ float3 grad = 2.0f * pos.xzy * float3(a, b, c)
+ + pos.zxz * float3(d, d, e)
+ + pos.yyx * float3(f, e, f)
+ + float3(g, h, i);
+ norm = -normalize(grad);
+ return true;
+ }
+
+ float sq = q * q - 4.0f * p * r;
+ if (sq < 0.0f) {
+ return false;
+ }
+
+ float s = sqrt(sq);
+ float t0 = (-q + s) / (2.0f * p);
+ float t1 = (-q - s) / (2.0f * p);
+ float tt1 = min(t0 < 0.0f ? t1 : t0, t1 < 0.0f ? t0 : t1);
+ float tt2 = max(t0 > 0.0f ? t1 : t0, t1 > 0.0f ? t0 : t1);
+ float tt0 = tt1;
+ if (tt0 <= 0.0f) {
+ return false;
+ }
+
+ float3 pos = ro + tt0 * rd;
+ bool ru = step(sz, length(pos)) > 0.5f;
+ if (ru) {
+ tt0 = tt2;
+ pos = ro + tt0 * rd;
+ }
+ if (tt0 <= 0.0f) {
+ return false;
+ }
+ bool ru2 = step(sz, length(pos)) > 0.5f;
+ if (ru2) {
+ return false;
+ }
+
+ if ((tt2 > 0.0f) && (!ru) && !(step(sz, length(ro + tt2 * rd)) > 0.5f)) {
+ si = true;
+ fadesi = s;
+ tsi = tt2;
+ float3 tpos = ro + tsi * rd;
+ float3 tgrad = 2.0f * tpos.xzy * float3(a, b, c)
+ + tpos.zxz * float3(d, d, e)
+ + tpos.yyx * float3(f, e, f)
+ + float3(g, h, i);
+ normsi = -normalize(tgrad);
+ }
+
+ fade = s;
+ t = tt0;
+ float3 grad = 2.0f * pos.xzy * float3(a, b, c)
+ + pos.zxz * float3(d, d, e)
+ + pos.yyx * float3(f, e, f)
+ + float3(g, h, i);
+ norm = -normalize(grad);
+ return true;
+}
+
+static float cclcDot2(float3 v) {
+ return dot(v, v);
+}
+
+static float cclcSegShadow(float3 ro, float3 rd, float3 pa, float sh) {
+ float dm = dot(rd.yz, rd.yz);
+ float k1 = (ro.x - pa.x) * dm;
+ float k2 = (ro.x + pa.x) * dm;
+ float2 k5 = (ro.yz + pa.yz) * dm;
+ float k3 = dot(ro.yz + pa.yz, rd.yz);
+ float2 k4 = (pa.yz + pa.yz) * rd.yz;
+ float2 k6 = (pa.yz + pa.yz) * dm;
+
+ for (int i = 0; i < 4; ++i) {
+ float2 s = float2(float(i & 1), float(i >> 1));
+ float t = dot(s, k4) - k3;
+ if (t > 0.0f) {
+ sh = min(sh, cclcDot2(float3(clamp(-rd.x * t, k1, k2), k5 - k6 * s) + rd * t) / (t * t));
+ }
+ }
+ return sh;
+}
+
+static float cclcBoxSoftShadow(float3 ro, float3 rd, float3 rad, float sk) {
+ rd += 0.0001f * (1.0f - abs(sign(rd)));
+ float3 rdd = rd;
+ float3 roo = ro;
+
+ float3 m = 1.0f / rdd;
+ float3 n = m * roo;
+ float3 k = abs(m) * rad;
+
+ float3 t1 = -n - k;
+ float3 t2 = -n + k;
+
+ float tN = max(max(t1.x, t1.y), t1.z);
+ float tF = min(min(t2.x, t2.y), t2.z);
+
+ if (tN < tF && tF > 0.0f) {
+ return 0.0f;
+ }
+
+ float sh = 1.0f;
+ sh = cclcSegShadow(roo.xyz, rdd.xyz, rad.xyz, sh);
+ sh = cclcSegShadow(roo.yzx, rdd.yzx, rad.yzx, sh);
+ sh = cclcSegShadow(roo.zxy, rdd.zxy, rad.zxy, sh);
+ sh = clamp(sk * sqrt(sh), 0.0f, 1.0f);
+ return sh * sh * (3.0f - 2.0f * sh);
+}
+
+static float cclcBox(float3 ro, float3 rd, float3 r, thread float3 &nn, bool entering) {
+ rd += 0.0001f * (1.0f - abs(sign(rd)));
+ float3 dr = 1.0f / rd;
+ float3 n = ro * dr;
+ float3 k = r * abs(dr);
+
+ float3 pin = -k - n;
+ float3 pout = k - n;
+ float tin = max(pin.x, max(pin.y, pin.z));
+ float tout = min(pout.x, min(pout.y, pout.z));
+ if (tin > tout) {
+ return -1.0f;
+ }
+ if (entering) {
+ nn = -sign(rd) * step(pin.zxy, pin.xyz) * step(pin.yzx, pin.xyz);
+ } else {
+ nn = sign(rd) * step(pout.xyz, pout.zxy) * step(pout.xyz, pout.yzx);
+ }
+ return entering ? tin : tout;
+}
+
+static float3 cclcBgCol(float3 rd) {
+ return mix(float3(0.01f), float3(0.336f, 0.458f, 0.668f), 1.0f - pow(abs(rd.z + 0.25f), 1.3f));
+}
+
+static float3 cclcBackground(float3 ro, float3 rd, float3 lDir, thread float &alpha) {
+ float t = (-CCLC_BOX_DIMS.z - ro.z) / rd.z;
+ alpha = 0.0f;
+ float3 bgc = cclcBgCol(rd);
+ if (t < 0.0f) {
+ return bgc;
+ }
+
+ float2 uv = ro.xy + t * rd.xy;
+ float3 shadowDir = cclcRotZRow(normalize(lDir + float3(0.0f, 0.0f, 1.0f)), CCLC_PI * 0.65f);
+ float shad = cclcBoxSoftShadow(ro + t * rd, shadowDir, CCLC_BOX_DIMS, 1.5f);
+
+ float aofac = smoothstep(-0.95f, 0.75f, length(abs(uv) - min(abs(uv), float2(0.45f))));
+ aofac = min(aofac, smoothstep(-0.65f, 1.0f, shad));
+ float lght = max(dot(normalize(ro + t * rd + float3(0.0f, 0.0f, -5.0f)),
+ cclcRotZRow(normalize(lDir - float3(0.0f, 0.0f, 1.0f)), CCLC_PI * 0.65f)), 0.0f);
+ float3 col = mix(float3(0.4f), float3(0.71f, 0.772f, 0.895f), lght * lght * aofac + 0.05f) * aofac;
+ alpha = 1.0f - smoothstep(7.0f, 10.0f, length(uv));
+ return mix(col * length(col) * 0.8f, bgc, smoothstep(7.0f, 10.0f, length(uv)));
+}
+
+static float4 cclcInsides(float3 ro, float3 rd, float3 norC, float3 lDir, float time, thread float &tout) {
+ tout = -1.0f;
+
+ if (abs(norC.x) > 0.5f) {
+ rd = rd.xzy * norC.x;
+ ro = ro.xzy * norC.x;
+ } else if (abs(norC.z) > 0.5f) {
+ lDir = cclcRotYRow(lDir, CCLC_PI);
+ rd = rd.yxz * norC.z;
+ ro = ro.yxz * norC.z;
+ } else if (abs(norC.y) > 0.5f) {
+ lDir = cclcRotZRow(lDir, -CCLC_PI * 0.5f);
+ rd = rd * norC.y;
+ ro = ro * norC.y;
+ }
+
+ // Shrink the authored lattice inside the same 2 m portal container.
+ ro *= CCLC_INTERIOR_SCENE_SCALE;
+ rd *= CCLC_INTERIOR_SCENE_SCALE;
+
+ float curvature = 0.5f;
+ float bilSize = 1.0f;
+ float4 ps = float4(-bilSize, -bilSize, bilSize, bilSize) * curvature;
+ float4 ph = float4(-bilSize, bilSize, bilSize, -bilSize) * curvature;
+
+ float4 colx[3];
+ float3 dx[3];
+ float4 colxsi[3];
+ int order[3];
+ for (int k = 0; k < 3; ++k) {
+ colx[k] = float4(0.0f);
+ dx[k] = float3(-1.0f);
+ colxsi[k] = float4(0.0f);
+ order[k] = k;
+ }
+
+ for (int i = 0; i < 3; ++i) {
+ if (abs(norC.x) > 0.5f) {
+ ro = cclcRotZRow(ro, -CCLC_PI / 3.0f);
+ rd = cclcRotZRow(rd, -CCLC_PI / 3.0f);
+ } else if (abs(norC.z) > 0.5f) {
+ ro = cclcRotZRow(ro, CCLC_PI / 3.0f);
+ rd = cclcRotZRow(rd, CCLC_PI / 3.0f);
+ } else if (abs(norC.y) > 0.5f) {
+ ro = cclcRotXRow(ro, CCLC_PI / 3.0f);
+ rd = cclcRotXRow(rd, CCLC_PI / 3.0f);
+ }
+
+ float3 normnew;
+ float tnew;
+ bool si;
+ float tsi;
+ float3 normsi;
+ float fade;
+ float fadesi;
+
+ if (cclcIBilinearPatch(ro, rd, ps, ph, bilSize, tnew, normnew, si, tsi, normsi, fade, fadesi) && tnew > 0.0f) {
+ float4 tcol = float4(0.0f);
+ float4 tcolsi = float4(0.0f);
+ cclcCalcColor(ro, rd, normnew, tnew, bilSize, i, si, tsi, time, tcol, tcolsi);
+ if (tcol.a > 0.0f) {
+ dx[i] = float3(tnew, float(si), tsi);
+
+ float dif = clamp(dot(normnew, lDir), 0.0f, 1.0f);
+ float amb = clamp(0.5f + 0.5f * dot(normnew, lDir), 0.0f, 1.0f);
+ float3 shad = float3(0.32f, 0.43f, 0.54f) * amb + float3(1.0f, 0.9f, 0.7f) * dif;
+ float3 tcr = float3(1.0f, 0.21f, 0.11f);
+ float ta = clamp(length(tcol.rgb), 0.0f, 1.0f);
+ tcol = clamp(tcol * tcol * 2.0f, 0.0f, 1.0f);
+ float4 tvalx = float4(
+ (tcol.rgb * shad * 1.4f + 3.0f * (tcr * tcol.rgb) * clamp(1.0f - (amb + dif), 0.0f, 1.0f)),
+ min(tcol.a, ta));
+ tvalx.rgb = clamp(2.0f * tvalx.rgb * tvalx.rgb, 0.0f, 1.0f);
+ tvalx *= min(fade * 5.0f, 1.0f);
+ colx[i] = tvalx;
+
+ if (si) {
+ dif = clamp(dot(normsi, lDir), 0.0f, 1.0f);
+ amb = clamp(0.5f + 0.5f * dot(normsi, lDir), 0.0f, 1.0f);
+ shad = float3(0.32f, 0.43f, 0.54f) * amb + float3(1.0f, 0.9f, 0.7f) * dif;
+ ta = clamp(length(tcolsi.rgb), 0.0f, 1.0f);
+ tcolsi = clamp(tcolsi * tcolsi * 2.0f, 0.0f, 1.0f);
+ float4 tvalxsi = float4(
+ tcolsi.rgb * shad + 3.0f * (tcr * tcolsi.rgb) * clamp(1.0f - (amb + dif), 0.0f, 1.0f),
+ min(tcolsi.a, ta));
+ tvalxsi.rgb = clamp(2.0f * tvalxsi.rgb * tvalxsi.rgb, 0.0f, 1.0f);
+ tvalxsi.rgb *= min(fadesi * 5.0f, 1.0f);
+ colxsi[i] = tvalxsi;
+ }
+ }
+ }
+ }
+
+ if (dx[0].x < dx[1].x) { float3 tv = dx[0]; dx[0] = dx[1]; dx[1] = tv; int to = order[0]; order[0] = order[1]; order[1] = to; }
+ if (dx[1].x < dx[2].x) { float3 tv = dx[1]; dx[1] = dx[2]; dx[2] = tv; int to = order[1]; order[1] = order[2]; order[2] = to; }
+ if (dx[0].x < dx[1].x) { float3 tv = dx[0]; dx[0] = dx[1]; dx[1] = tv; int to = order[0]; order[0] = order[1]; order[1] = to; }
+
+ tout = max(max(dx[0].x, dx[1].x), dx[2].x);
+
+ bool rul0 = (dx[0].y > 0.5f) && (dx[1].x <= 0.0f);
+ bool rul1 = (dx[1].y > 0.5f) && (dx[0].x > dx[1].z);
+ bool rul2 = (dx[2].y > 0.5f) && (dx[1].x > dx[2].z);
+ bool rul[3] = {rul0, rul1, rul2};
+ for (int k = 0; k < 3; ++k) {
+ if (rul[k]) {
+ float4 tcolxsi = colxsi[order[k]];
+ float4 tcolx = colx[order[k]];
+ float4 tvalx = mix(tcolxsi, tcolx, tcolx.a);
+ colx[order[k]] = mix(float4(0.0f), tvalx, max(tcolx.a, tcolxsi.a));
+ }
+ }
+
+ float a1 = (dx[1].y < 0.5f) ? colx[order[1]].a : ((dx[1].z > dx[0].x) ? colx[order[1]].a : 1.0f);
+ float a2 = (dx[2].y < 0.5f) ? colx[order[2]].a : ((dx[2].z > dx[1].x) ? colx[order[2]].a : 1.0f);
+ float3 col = mix(mix(colx[order[0]].rgb, colx[order[1]].rgb, a1), colx[order[2]].rgb, a2);
+ float a = max(max(colx[order[0]].a, a1), a2);
+ return float4(col, a);
+}
+
+fragment float4 crystalCubeLatticinioCore1Fragment(
+ CrystalCubeLatticinioCore1VertexOut in [[stage_in]],
+ constant CrystalCubeLatticinioCore1Uniforms &uniforms [[buffer(0)]],
+ constant float4x4 *viewToWorld [[buffer(1)]])
+{
+ uint vi = min(in.viewIndex, uniforms.viewCount - 1u);
+ float4x4 v2w = viewToWorld[vi];
+
+ float3 center = uniforms.objectCenter.xyz;
+ float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z);
+ float sceneScale = max(uniforms.cubeScale, 1.0e-4f);
+ float3 eye = (camWorld - center) / sceneScale;
+ float3 surfaceWorld = in.worldPos;
+ float3 viewRd = normalize(surfaceWorld - camWorld);
+
+ float3 lightDir = normalize(float3(0.0f, 1.0f, 0.0f));
+ lightDir = cclcRotZRow(lightDir, 0.5f);
+
+ bool insideBox = all(abs(eye) < float3(0.999f));
+ float3 ni;
+ float hitT;
+ float3 facePoint;
+ float3 portalDir;
+ if (insideBox) {
+ hitT = cclcBox(eye, viewRd, CCLC_BOX_DIMS, ni, false);
+ if (hitT < 0.0f) {
+ discard_fragment();
+ }
+ facePoint = eye + hitT * viewRd;
+ ni = -ni;
+ portalDir = -viewRd;
+ } else {
+ hitT = cclcBox(eye, viewRd, CCLC_BOX_DIMS, ni, true);
+ if (hitT < 0.0f) {
+ float alpha;
+ return float4(cclcBackground(eye, viewRd, lightDir, alpha), 1.0f);
+ }
+ facePoint = eye + hitT * viewRd;
+ portalDir = viewRd;
+ }
+
+ float2 coords = facePoint.xy * ni.z / CCLC_BOX_DIMS.xy
+ + facePoint.yz * ni.x / CCLC_BOX_DIMS.yz
+ + facePoint.zx * ni.y / CCLC_BOX_DIMS.zx;
+ float fadeBorders = (1.0f - smoothstep(0.915f, 1.05f, abs(coords.x)))
+ * (1.0f - smoothstep(0.915f, 1.05f, abs(coords.y)));
+
+ float time = uniforms.time;
+ float R0 = (CCLC_IOR - 1.0f) / (CCLC_IOR + 1.0f);
+ R0 *= R0;
+
+ float3 ro = facePoint + portalDir * 0.002f;
+ float3 nr = ni;
+ float3 rdr = reflect(portalDir, nr);
+ float talpha;
+ float3 reflcol = cclcBackground(ro, rdr, lightDir, talpha);
+
+ float3 rd2 = refract(portalDir, nr, 1.0f / CCLC_IOR);
+ if (all(rd2 == float3(0.0f))) {
+ rd2 = reflect(portalDir, nr);
+ }
+
+ float accum = 1.0f;
+ float3 no2 = ni;
+ float3 roRefr = ro;
+ float4 colo[2];
+ colo[0] = float4(0.0f);
+ colo[1] = float4(0.0f);
+
+ for (int j = 0; j < 2; ++j) {
+ float tb;
+ float2 coords2 = roRefr.xy * no2.z + roRefr.yz * no2.x + roRefr.zx * no2.y;
+ float3 eye2 = float3(coords2, -1.0f);
+ float3 rd2trans = rd2.yzx * no2.x + rd2.zxy * no2.y + rd2.xyz * no2.z;
+ rd2trans.z = -rd2trans.z;
+
+ float4 internalcol = cclcInsides(eye2, rd2trans, no2, lightDir, time, tb);
+ if (tb > 0.0f) {
+ internalcol.rgb *= accum;
+ colo[j] = internalcol;
+ }
+
+ if ((tb <= 0.0f) || (internalcol.a < 1.0f)) {
+ float tout = cclcBox(roRefr, rd2, CCLC_BOX_DIMS, no2, false);
+ no2 = nr.zyx * no2.x + nr.xzy * no2.y + nr.yxz * no2.z;
+ float3 rout = roRefr + tout * rd2;
+ float3 rdout = refract(rd2, -no2, CCLC_IOR);
+ float fresnel2 = R0 + (1.0f - R0) * pow(1.0f - dot(rdout, no2), 1.3f);
+ rd2 = reflect(rd2, -no2);
+ roRefr = rout;
+ roRefr.z = max(roRefr.z, -0.999f);
+ accum *= fresnel2;
+ }
+ }
+
+ float fresnel = R0 + (1.0f - R0) * pow(1.0f - dot(-portalDir, nr), 5.0f);
+ float3 col = mix(mix(colo[1].rgb * colo[1].a, colo[0].rgb, colo[0].a) * fadeBorders,
+ reflcol,
+ pow(fresnel, 1.5f));
+ col = clamp(col, 0.0f, 1.0f);
+ return float4(col, 1.0f);
+}
diff --git a/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Types.swift b/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Types.swift
new file mode 100644
index 0000000..08bac30
--- /dev/null
+++ b/vr-dive/Demos/CrystalCubeLatticinioCore1/CrystalCubeLatticinioCore1Types.swift
@@ -0,0 +1,10 @@
+import simd
+
+/// Must stay in sync with the Metal struct CrystalCubeLatticinioCore1Uniforms in CrystalCubeLatticinioCore1Shaders.metal.
+struct CrystalCubeLatticinioCore1Uniforms {
+ var time: Float
+ var viewCount: UInt32
+ var cubeScale: Float
+ var padding: Float
+ var objectCenter: SIMD4
+}
diff --git a/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoRenderer.swift b/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoRenderer.swift
new file mode 100644
index 0000000..affd1fd
--- /dev/null
+++ b/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoRenderer.swift
@@ -0,0 +1,482 @@
+import Metal
+import simd
+
+private struct CRMVertex {
+ var position: SIMD3
+ var normal: SIMD3
+ var color: SIMD3
+}
+
+private struct CRMGeometry {
+ var vertices: [CRMVertex] = []
+ var indices: [UInt32] = []
+
+ mutating func append(vertices newVertices: [CRMVertex], indices newIndices: [UInt32]) {
+ let base = UInt32(vertices.count)
+ vertices += newVertices
+ indices += newIndices.map { $0 + base }
+ }
+}
+
+private struct CRMTraceStep {
+ var distanceAlongRay: Float
+ var radius: Float
+}
+
+private struct CRMTraceResult {
+ var steps: [CRMTraceStep]
+ var hitDistance: Float
+}
+
+private let crmTraceEpsilon: Float = 0.005
+
+private func crmBasis(for axis: SIMD3) -> (SIMD3, SIMD3) {
+ let tangent = simd_normalize(axis)
+ let helper = abs(tangent.x) > 0.9 ? SIMD3(0, 1, 0) : SIMD3(1, 0, 0)
+ let u = simd_normalize(simd_cross(tangent, helper))
+ return (u, simd_cross(tangent, u))
+}
+
+private func crmAppendSphere(
+ _ geometry: inout CRMGeometry,
+ center: SIMD3,
+ radius: Float,
+ color: SIMD3,
+ latSegments: Int = 10,
+ lonSegments: Int = 20
+) {
+ var vertices: [CRMVertex] = []
+ var indices: [UInt32] = []
+ vertices.reserveCapacity((latSegments + 1) * (lonSegments + 1))
+ indices.reserveCapacity(latSegments * lonSegments * 6)
+
+ for lat in 0...latSegments {
+ let theta = Float(lat) * .pi / Float(latSegments)
+ let sinTheta = sin(theta)
+ let cosTheta = cos(theta)
+ for lon in 0...lonSegments {
+ let phi = Float(lon) * 2 * .pi / Float(lonSegments)
+ let normal = SIMD3(cos(phi) * sinTheta, cosTheta, sin(phi) * sinTheta)
+ vertices.append(CRMVertex(position: center + normal * radius, normal: normal, color: color))
+ }
+ }
+
+ let ring = lonSegments + 1
+ for lat in 0..,
+ to end: SIMD3,
+ radius: Float,
+ color: SIMD3,
+ radialSegments: Int = 12
+) {
+ let axis = end - start
+ let length = simd_length(axis)
+ guard length > 1e-5 else { return }
+ let tangent = axis / length
+ let (u, v) = crmBasis(for: tangent)
+
+ var vertices: [CRMVertex] = []
+ var indices: [UInt32] = []
+ vertices.reserveCapacity((radialSegments + 1) * 2)
+ indices.reserveCapacity(radialSegments * 6)
+
+ for ringIndex in 0...1 {
+ let center = ringIndex == 0 ? start : end
+ for segment in 0...radialSegments {
+ let angle = Float(segment) * 2 * .pi / Float(radialSegments)
+ let normal = simd_normalize(cos(angle) * u + sin(angle) * v)
+ vertices.append(CRMVertex(position: center + normal * radius, normal: normal, color: color))
+ }
+ }
+
+ let ring = radialSegments + 1
+ for segment in 0..,
+ axis: SIMD3,
+ radius: Float,
+ tubeRadius: Float,
+ color: SIMD3,
+ segments: Int = 40
+) {
+ let (u, v) = crmBasis(for: axis)
+ var lastPoint = center + u * radius
+ for segment in 1...segments {
+ let angle = Float(segment) * 2 * .pi / Float(segments)
+ let point = center + (cos(angle) * u + sin(angle) * v) * radius
+ crmAppendCylinder(
+ &geometry, from: lastPoint, to: point, radius: tubeRadius, color: color, radialSegments: 8)
+ lastPoint = point
+ }
+}
+
+private func crmAppendWireSphere(
+ _ geometry: inout CRMGeometry,
+ center: SIMD3,
+ radius: Float,
+ wireRadius: Float,
+ color: SIMD3,
+ guideDirection: SIMD3
+) {
+ let ray = simd_normalize(guideDirection)
+ let (u, v) = crmBasis(for: ray)
+ crmAppendCircle(
+ &geometry, center: center, axis: u, radius: radius, tubeRadius: wireRadius, color: color)
+ crmAppendCircle(
+ &geometry, center: center, axis: v, radius: radius, tubeRadius: wireRadius, color: color)
+ crmAppendCircle(
+ &geometry, center: center, axis: ray, radius: radius, tubeRadius: wireRadius, color: color)
+}
+
+private func crmAppendTorus(
+ _ geometry: inout CRMGeometry,
+ center: SIMD3,
+ majorRadius: Float,
+ minorRadius: Float,
+ color: SIMD3,
+ ringSegments: Int = 28,
+ tubeSegments: Int = 14
+) {
+ var vertices: [CRMVertex] = []
+ var indices: [UInt32] = []
+ vertices.reserveCapacity((ringSegments + 1) * (tubeSegments + 1))
+ indices.reserveCapacity(ringSegments * tubeSegments * 6)
+
+ for ring in 0...ringSegments {
+ let u = Float(ring) * 2 * .pi / Float(ringSegments)
+ let cu = cos(u)
+ let su = sin(u)
+ let ringCenter = center + SIMD3(majorRadius * cu, 0, majorRadius * su)
+ let ringNormal = SIMD3(cu, 0, su)
+ for tube in 0...tubeSegments {
+ let v = Float(tube) * 2 * .pi / Float(tubeSegments)
+ let cv = cos(v)
+ let sv = sin(v)
+ let normal = simd_normalize(SIMD3(ringNormal.x * cv, sv, ringNormal.z * cv))
+ let position = ringCenter + normal * minorRadius
+ vertices.append(CRMVertex(position: position, normal: normal, color: color))
+ }
+ }
+
+ let stride = tubeSegments + 1
+ for ring in 0..,
+ halfExtents: SIMD3,
+ radius: Float,
+ color: SIMD3
+) {
+ let corners: [SIMD3] = [
+ center + SIMD3(-halfExtents.x, -halfExtents.y, -halfExtents.z),
+ center + SIMD3(halfExtents.x, -halfExtents.y, -halfExtents.z),
+ center + SIMD3(halfExtents.x, halfExtents.y, -halfExtents.z),
+ center + SIMD3(-halfExtents.x, halfExtents.y, -halfExtents.z),
+ center + SIMD3(-halfExtents.x, -halfExtents.y, halfExtents.z),
+ center + SIMD3(halfExtents.x, -halfExtents.y, halfExtents.z),
+ center + SIMD3(halfExtents.x, halfExtents.y, halfExtents.z),
+ center + SIMD3(-halfExtents.x, halfExtents.y, halfExtents.z),
+ ]
+ let edges = [
+ (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7),
+ ]
+ for (a, b) in edges {
+ crmAppendCylinder(&geometry, from: corners[a], to: corners[b], radius: radius, color: color)
+ }
+}
+
+private func crmSdTorus(
+ _ p: SIMD3, _ center: SIMD3, _ majorRadius: Float, _ minorRadius: Float
+) -> Float {
+ let q = p - center
+ return simd_length(SIMD2(simd_length(SIMD2(q.x, q.z)) - majorRadius, q.y))
+ - minorRadius
+}
+
+private func crmTrace(
+ from start: SIMD3,
+ rayDir: SIMD3,
+ maxDistance: Float,
+ maxSteps: Int,
+ distance: (SIMD3) -> Float
+) -> CRMTraceResult {
+ var t: Float = 0
+ var steps: [CRMTraceStep] = []
+ steps.reserveCapacity(maxSteps)
+ for _ in 0..= maxDistance { break }
+ }
+ return CRMTraceResult(steps: steps, hitDistance: min(t, maxDistance))
+}
+
+private func crmRefineHitDistance(
+ from start: SIMD3,
+ rayDir: SIMD3,
+ nearDistance: Float,
+ nearSdf: Float,
+ maxDistance: Float,
+ distance: (SIMD3) -> Float
+) -> Float {
+ var outsideT = nearDistance
+ var highT = nearDistance + max(nearSdf * 1.5, 0.002)
+
+ for _ in 0..<32 {
+ if highT >= maxDistance { return min(outsideT, maxDistance) }
+ let highD = distance(start + rayDir * highT)
+ if highD <= 0 {
+ var low = outsideT
+ var high = highT
+ for _ in 0..<24 {
+ let mid = 0.5 * (low + high)
+ let midD = distance(start + rayDir * mid)
+ if midD > 0 {
+ low = mid
+ } else {
+ high = mid
+ }
+ }
+ return 0.5 * (low + high)
+ }
+ outsideT = highT
+ highT += max(highD * 1.25, 0.002)
+ }
+
+ return min(outsideT, maxDistance)
+}
+
+private func crmProbeColor(_ base: SIMD3, stepIndex: Int) -> SIMD3 {
+ if stepIndex.isMultiple(of: 2) { return base }
+ return simd_clamp(
+ base * 0.45 + SIMD3