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(base.z, base.x, base.y) * 0.35 + SIMD3(0.20, 0.20, 0.20), + .zero, + SIMD3(repeating: 1.0) + ) +} + +private func crmAppendProbeSpheres( + _ geometry: inout CRMGeometry, + start: SIMD3, + rayDir: SIMD3, + steps: [CRMTraceStep], + color: SIMD3, + maxProbes: Int +) { + for (count, step) in steps.prefix(maxProbes).enumerated() { + let p = start + rayDir * step.distanceAlongRay + let probeColor = crmProbeColor(color, stepIndex: count) + crmAppendWireSphere( + &geometry, center: p, radius: step.radius, wireRadius: 0.00125, color: probeColor, + guideDirection: rayDir) + } +} + +final class CubeRayMarchDemoRenderer: VisualPatternController { + let identifier: VisualPatternKind = .cubeRayMarchDemo + 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 objectCenter = SIMD3(0.0, 0.0, -1.9) + private let cubeCenter = SIMD3(0.0, 0.0, 0.0) + private let cubeHalfExtents = SIMD3(0.85, 0.85, 0.85) + private let lightLocal = SIMD3(-0.84, 0.54, -0.35) + private let torusCenter = SIMD3(0.30, 0.0, 0.0) + private let torusMajorRadius: Float = 0.35 + private let torusMinorRadius: Float = 0.09 + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + let lightLocal = SIMD3(-0.84, 0.54, -0.35) + let torusCenter = SIMD3(0.30, 0.0, 0.0) + let torusMajorRadius: Float = 0.35 + let torusMinorRadius: Float = 0.09 + // Chosen from the side face so the exact sphere-tracing sequence first hits the farther ring. + let rayAim = SIMD3(0.80, -0.16, -0.15) + let rayDir = simd_normalize(rayAim - lightLocal) + let trace = crmTrace( + from: lightLocal, + rayDir: rayDir, + maxDistance: 4.0, + maxSteps: 96 + ) { point in + crmSdTorus(point, torusCenter, torusMajorRadius, torusMinorRadius) + } + let rayEnd = lightLocal + rayDir * trace.hitDistance + + let cCube = SIMD3(0.28, 0.90, 0.45) + let cLight = SIMD3(1.0, 0.95, 0.5) + let cTorus = SIMD3(1.0, 0.60, 0.16) + let cRay = SIMD3(0.40, 0.78, 1.0) + let cProbe = SIMD3(0.92, 0.86, 0.36) + + var geometry = CRMGeometry() + crmAppendBoxEdges( + &geometry, center: cubeCenter, halfExtents: cubeHalfExtents, radius: 0.016, color: cCube) + crmAppendTorus( + &geometry, center: torusCenter, majorRadius: torusMajorRadius, minorRadius: torusMinorRadius, + color: cTorus) + crmAppendSphere( + &geometry, center: lightLocal, radius: 0.07, color: cLight, latSegments: 10, lonSegments: 20) + crmAppendCylinder( + &geometry, from: lightLocal, to: rayEnd, radius: 0.003, color: cRay, radialSegments: 10) + crmAppendProbeSpheres( + &geometry, start: lightLocal, rayDir: rayDir, steps: trace.steps, color: cProbe, + maxProbes: 24) + + vertexBuffer = device.makeBuffer( + bytes: geometry.vertices, + length: MemoryLayout.stride * geometry.vertices.count, + options: .storageModeShared + )! + indexBuffer = device.makeBuffer( + bytes: geometry.indices, + length: MemoryLayout.stride * geometry.indices.count, + options: .storageModeShared + )! + indexCount = geometry.indices.count + + pipelineState = try CubeRayMarchDemoRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = CubeRayMarchDemoRenderer.makeDepthStencilState(device: device) + } + + func updateSimulation(_ context: PatternSimulationContext) {} + func resetToInitialState() {} + + 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 = CRMMeshUniforms( + time: context.time, + viewCount: UInt32(context.viewData.viewCount), + objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0), + lightPosition: SIMD4(lightLocal.x, lightLocal.y, lightLocal.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: .uint32, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } +} + +extension CubeRayMarchDemoRenderer { + fileprivate static func makePipelineState( + device: MTLDevice, + library: MTLLibrary, + maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "cubeRayMarchVertex") + desc.fragmentFunction = library.makeFunction(name: "cubeRayMarchFragment") + 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.attributes[2].format = .float3 + vertexDescriptor.attributes[2].offset = MemoryLayout>.stride * 2 + vertexDescriptor.attributes[2].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/CubeRayMarchDemo/CubeRayMarchDemoShaders.metal b/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoShaders.metal new file mode 100644 index 0000000..f6a9878 --- /dev/null +++ b/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoShaders.metal @@ -0,0 +1,73 @@ +// CubeRayMarchDemoShaders.metal +// Standard mesh shading for CPU-generated probe spheres, rays, cube frame and torus. +#include +using namespace metal; + +struct CRMVertex { + float3 position; + float3 normal; + float3 color; +}; + +struct CRMUniforms { + float time; + uint viewCount; + float2 pad0; + float4 objectCenter; + float4 lightPosition; +}; + +struct CRMOut { + float4 clipPos [[position]]; + float3 worldPos; + float3 normal; + float3 color; + uint viewIndex [[flat]]; +}; + +vertex CRMOut cubeRayMarchVertex( + ushort amplificationID [[amplification_id]], + const device CRMVertex *vertices [[buffer(0)]], + constant CRMUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + CRMVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position + uniforms.objectCenter.xyz; + + CRMOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.normal = vtx.normal; + out.color = vtx.color; + out.viewIndex = viewIndex; + return out; +} + +fragment float4 cubeRayMarchFragment( + CRMOut in [[stage_in]], + constant CRMUniforms &uniforms [[buffer(0)]], + constant float4x4 *viewToWorld [[buffer(1)]], + bool frontFacing [[front_facing]]) +{ + 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 lightWorld = uniforms.objectCenter.xyz + uniforms.lightPosition.xyz; + + float3 N = normalize(in.normal); + if (!frontFacing) N = -N; + float3 L = normalize(lightWorld - in.worldPos); + float3 V = normalize(camWorld - in.worldPos); + float3 H = normalize(L + V); + + float diffuse = max(dot(N, L), 0.0f); + float specular = pow(max(dot(N, H), 0.0f), 42.0f); + float rim = pow(1.0f - max(dot(N, V), 0.0f), 2.3f); + + float3 color = in.color * (0.16f + 0.84f * diffuse); + color += in.color * rim * 0.20f; + color += float3(1.0f, 0.98f, 0.92f) * specular * 0.30f; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoTypes.swift b/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoTypes.swift new file mode 100644 index 0000000..f86b8fd --- /dev/null +++ b/vr-dive/Demos/CubeRayMarchDemo/CubeRayMarchDemoTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Synced with CRMUniforms in CubeRayMarchDemoShaders.metal +struct CRMMeshUniforms { + var time: Float + var viewCount: UInt32 + var pad0: SIMD2 = .zero + var objectCenter: SIMD4 + var lightPosition: SIMD4 +} diff --git a/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionRenderer.swift b/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionRenderer.swift new file mode 100644 index 0000000..4457153 --- /dev/null +++ b/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionRenderer.swift @@ -0,0 +1,172 @@ +import Metal +import simd + +// CubicSpaceDivisionRenderer.swift +// +// Original implementation for a cube-contained adaptation of Escher-inspired +// cubic space division geometry. +// Visual inspiration requested from ShaderToy 4ltyWl: +// https://www.shadertoy.com/view/4ltyWl +// The original source is not reused here; this renderer drives a clean-room +// Metal implementation adapted to the app's cube portal rendering model. + +final class CubicSpaceDivisionRenderer: VisualPatternController { + let identifier: VisualPatternKind = .cubicSpaceDivision + let preferredClearColor = MTLClearColor(red: 1, green: 1, blue: 1, 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 + + // The unit cube mesh spans [-1, 1], so 1.0 yields a 2 m wide container. + private let cubeScale: Float = 1.0 + private let travelSpeed: Float = 1.0 + private let objectCenter = SIMD3(0.0, 0.0, -1.35) + + private var animationTime: Float = 0 + private var lastSimulationTime: Float? + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + let geo = CubicSpaceDivisionRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try CubicSpaceDivisionRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = CubicSpaceDivisionRenderer.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 = CubicSpaceDivisionUniforms( + 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 CubicSpaceDivisionRenderer { + 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: "cubicSpaceDivisionVertex") + desc.fragmentFunction = library.makeFunction(name: "cubicSpaceDivisionFragment") + 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/CubicSpaceDivision/CubicSpaceDivisionShaders.metal b/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionShaders.metal new file mode 100644 index 0000000..cd2e0a0 --- /dev/null +++ b/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionShaders.metal @@ -0,0 +1,211 @@ +// CubicSpaceDivisionShaders.metal +// +// Original implementation for a cube-contained cubic space division scene. +// Visual inspiration requested from ShaderToy 4ltyWl. +// Reference link: https://www.shadertoy.com/view/4ltyWl +// The source from the reference shader is not reused here. This is a +// clean-room Metal implementation adapted for the app's cube portal. + +#include +using namespace metal; + +#define CSD_PI 3.14159265359f +#define CSD_MAX_STEPS 140 +#define CSD_MIN_DIST 0.02f +#define CSD_MAX_DIST 180.0f +#define CSD_EPS 0.0009f +#define CSD_CELL_SPACING 20.0f +#define CSD_EDGE_SIZE 0.08f +#define CSD_EDGE_SMOOTH 0.05f +#define CSD_SCENE_SCALE 36.0f + +struct CubicSpaceDivisionUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct CubicSpaceDivisionVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct CSDTraceResult { + float depth; + bool hit; +}; + +vertex CubicSpaceDivisionVertexOut cubicSpaceDivisionVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant CubicSpaceDivisionUniforms &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; + + CubicSpaceDivisionVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3x3 csdRotateX(float angle) { + float s = sin(angle); + float c = cos(angle); + return float3x3( + float3(1.0f, 0.0f, 0.0f), + float3(0.0f, c, -s), + float3(0.0f, s, c)); +} + +static float3x3 csdRotateZ(float angle) { + float s = sin(angle); + float c = cos(angle); + return float3x3( + float3(c, -s, 0.0f), + float3(s, c, 0.0f), + float3(0.0f, 0.0f, 1.0f)); +} + +static float3 csdMod(float3 x, float3 y) { + return x - y * floor(x / y); +} + +static float csdBoxSDF(float3 p, float3 size) { + float3 d = abs(p) - size * 0.5f; + float insideDistance = min(max(d.x, max(d.y, d.z)), 0.0f); + float outsideDistance = length(max(d, 0.0f)); + return insideDistance + outsideDistance; +} + +static float csdColumnSDF(float3 p, float thickness) { + float2 d = abs(p.xz) - float2(thickness * 0.5f); + float insideDistance = min(max(d.x, d.y), 0.0f); + float outsideDistance = length(max(d, 0.0f)); + return insideDistance + outsideDistance; +} + +static float csdColumnsSDF(float3 p, float spacing) { + float3 cell = float3(spacing, 100000.0f, spacing); + float3 q = csdMod(p, cell) - 0.5f * cell; + return csdColumnSDF(q, 1.0f); +} + +static float csdSceneSDF(float3 p) { + float columns1 = csdColumnsSDF(p, CSD_CELL_SPACING); + float columns2 = csdColumnsSDF(csdRotateZ(CSD_PI * 0.5f) * p, CSD_CELL_SPACING); + float columns3 = csdColumnsSDF(csdRotateX(CSD_PI * 0.5f) * p, CSD_CELL_SPACING); + float columns = min(columns1, min(columns2, columns3)); + + float3 repeated = csdMod(p, float3(CSD_CELL_SPACING)) - 0.5f * CSD_CELL_SPACING; + float box = csdBoxSDF(repeated, float3(3.0f)); + return min(columns, box); +} + +static float3 csdEstimateNormal(float3 p) { + float2 e = float2(CSD_EPS, 0.0f); + return normalize(float3( + csdSceneSDF(p + e.xyy) - csdSceneSDF(p - e.xyy), + csdSceneSDF(p + e.yxy) - csdSceneSDF(p - e.yxy), + csdSceneSDF(p + e.yyx) - csdSceneSDF(p - e.yyx))); +} + +static float3 csdDiffuse(float3 kd, float3 p, float3 eye, float3 lightDir, float3 intensity) { + float3 n = csdEstimateNormal(p); + float3 l = normalize(lightDir); + float dotLN = dot(l, n); + if (dotLN < 0.0f) return float3(0.0f); + return intensity * (kd * dotLN); +} + +static float3 csdFog(float3 rgb, float distance) { + float fogAmount = 1.0f - exp(-distance * pow(0.03f, 1.4f)); + return mix(rgb, float3(0.80f, 0.82f, 0.86f), fogAmount); +} + +static bool csdBoxHit( + 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 CSDTraceResult csdRayMarch(float3 eye, float3 direction) { + float depth = CSD_MIN_DIST; + + for (int i = 0; i < CSD_MAX_STEPS; ++i) { + float dist = csdSceneSDF(eye + depth * direction); + + if (dist < CSD_EPS) { + return CSDTraceResult { depth, true }; + } + + depth += dist; + if (depth >= CSD_MAX_DIST) { + return CSDTraceResult { CSD_MAX_DIST, false }; + } + } + + return CSDTraceResult { CSD_MAX_DIST, false }; +} + +static float3 csdComputeColor(float3 eye, float3 direction) { + CSDTraceResult trace = csdRayMarch(eye, direction); + if (!trace.hit) { + return float3(0.95f); + } + + float3 p = eye + trace.depth * direction; + float3 ambient = float3(0.44f, 0.46f, 0.50f); + float3 kd = float3(0.12f, 0.13f, 0.15f); + float3 color = ambient; + color += csdDiffuse(kd, p, eye, float3(0.3f, 0.5f, -1.0f), float3(1.6f)); + color = csdFog(color, trace.depth); + return pow(max(color, 0.0f), float3(1.5f)); +} + +fragment float4 cubicSpaceDivisionFragment( + CubicSpaceDivisionVertexOut in [[stage_in]], + constant CubicSpaceDivisionUniforms &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 rdWorld = normalize(in.worldPos - camWorld); + float3 roLocal = (camWorld - center) / uniforms.cubeScale; + float3 rdLocal = rdWorld; + + float tEntry; + float tExit; + if (!csdBoxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float sceneTime = uniforms.time * uniforms.travelSpeed; + float3 sceneEye = float3(0.7f, 0.83f, 1.8f) * 25.0f + float3(7.0f, 8.0f, 3.0f - sceneTime * 4.0f); + float3 sceneDir = normalize(float3(rdLocal.x, rdLocal.y, rdLocal.z)); + + float3 color = csdComputeColor(sceneEye, sceneDir); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionTypes.swift b/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionTypes.swift new file mode 100644 index 0000000..c9212a3 --- /dev/null +++ b/vr-dive/Demos/CubicSpaceDivision/CubicSpaceDivisionTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct CubicSpaceDivisionUniforms in +/// CubicSpaceDivisionShaders.metal. +struct CubicSpaceDivisionUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/DigitalLines/DigitalLinesRenderer.swift b/vr-dive/Demos/DigitalLines/DigitalLinesRenderer.swift new file mode 100644 index 0000000..3431334 --- /dev/null +++ b/vr-dive/Demos/DigitalLines/DigitalLinesRenderer.swift @@ -0,0 +1,160 @@ +import Metal +import simd + +// DigitalLinesRenderer.swift +// "Digital Lines" — cube-portal adaptation of Shadertoy "scf3zB" +// Original: https://www.shadertoy.com/view/scf3zB + +final class DigitalLinesRenderer: VisualPatternController { + let identifier: VisualPatternKind = .digitalLines + 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 = DigitalLinesRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try DigitalLinesRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = DigitalLinesRenderer.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 = DigitalLinesUniforms( + 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 DigitalLinesRenderer { + 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 vb = device.makeBuffer( + bytes: vertices, + length: MemoryLayout.stride * vertices.count, options: .storageModeShared)! + let ib = device.makeBuffer( + bytes: indices, + length: MemoryLayout.stride * indices.count, options: .storageModeShared)! + return (vb, ib, indices.count) + } + + fileprivate static func makePipelineState( + device: MTLDevice, library: MTLLibrary, maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "digitalLinesVertex") + desc.fragmentFunction = library.makeFunction(name: "digitalLinesFragment") + 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/DigitalLines/DigitalLinesShaders.metal b/vr-dive/Demos/DigitalLines/DigitalLinesShaders.metal new file mode 100644 index 0000000..85d8c72 --- /dev/null +++ b/vr-dive/Demos/DigitalLines/DigitalLinesShaders.metal @@ -0,0 +1,231 @@ +// DigitalLinesShaders.metal +// "Digital Lines" — cube-portal adaptation of Shadertoy "scf3zB" +// Original: https://www.shadertoy.com/view/scf3zB +// Adapted for visionOS Metal stereo rendering. +// +// Design: +// A 2 m cube acts as the portal container. For every fragment on the cube +// surface (or back-faces when the camera is inside), the fragment shader +// projects the view ray onto a world-fixed 2D plane and evaluates the +// original Shadertoy's dodecahedron wireframe loop at that UV. +// The inner scene is unbounded — the dodecahedron extends wherever it +// happens to be in UV space regardless of where the cube surface is. + +#include +using namespace metal; + +struct DigitalLinesUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct DigitalLinesVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Constants — dodecahedron geometry (from original shader). +// phi = golden ratio = (1 + sqrt(5)) / 2 ≈ 1.618 +// --------------------------------------------------------------------------- +static constant float DL_PHI = 1.6180339887f; +static constant float DL_INVP = 0.6180339887f; // 1 / phi +static constant float3 DL_BOX_HALF = float3(1.0f); + +static constant float3 DL_VERTS[20] = { + {-1.0f,-1.0f,-1.0f}, { 1.0f,-1.0f,-1.0f}, { 1.0f, 1.0f,-1.0f}, {-1.0f, 1.0f,-1.0f}, + {-1.0f,-1.0f, 1.0f}, { 1.0f,-1.0f, 1.0f}, { 1.0f, 1.0f, 1.0f}, {-1.0f, 1.0f, 1.0f}, + { 0.0f,-DL_INVP,-DL_PHI}, { 0.0f, DL_INVP,-DL_PHI}, + { 0.0f, DL_INVP, DL_PHI}, { 0.0f,-DL_INVP, DL_PHI}, + {-DL_INVP,-DL_PHI, 0.0f}, { DL_INVP,-DL_PHI, 0.0f}, + { DL_INVP, DL_PHI, 0.0f}, {-DL_INVP, DL_PHI, 0.0f}, + {-DL_PHI, 0.0f,-DL_INVP}, { DL_PHI, 0.0f,-DL_INVP}, + { DL_PHI, 0.0f, DL_INVP}, {-DL_PHI, 0.0f, DL_INVP} +}; + +// 30 edges × 2 vertex indices = 60 entries. +static constant int DL_EDGES[60] = { + 0, 8, 1, 8, 8, 9, 2, 9, 3, 9, + 0,16, 3,16, 16,19, 4,19, 7,19, + 1,17, 2,17, 17,18, 5,18, 6,18, + 4,12, 5,12, 12,13, 0,13, 1,13, + 2,14, 3,14, 14,15, 6,15, 7,15, + 4,11, 5,11, 10,11, 6,10, 7,10 +}; + +// --------------------------------------------------------------------------- +// Helper functions (translated from original GLSL, all names prefixed "dl") +// --------------------------------------------------------------------------- + +// Three-stop colour ramp: a→b for t in [0,0.5], b→c for t in [0.5,1]. +static float3 dlLerp3(float3 a, float3 b, float3 c, float t) { + if (t < 0.5f) return mix(a, b, t * 2.0f); + else return mix(b, c, (t - 0.5f) * 2.0f); +} + +// Rotation matrices — Metal float3x3 is column-major, matching GLSL mat3(). +// GLSL mat3(1,0,0, 0,c,-s, 0,s,c): col0=(1,0,0), col1=(0,c,-s), col2=(0,s,c) +static float3x3 dlRotX(float a) { + float s = sin(a), c = cos(a); + return float3x3(float3(1,0,0), float3(0,c,-s), float3(0,s,c)); +} +// GLSL mat3(c,0,s, 0,1,0, -s,0,c): col0=(c,0,s), col1=(0,1,0), col2=(-s,0,c) +static float3x3 dlRotY(float a) { + float s = sin(a), c = cos(a); + return float3x3(float3(c,0,s), float3(0,1,0), float3(-s,0,c)); +} + +static float2 dlBoxIntersect(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 dlRaySegmentDistance(float3 ro, float3 rd, float3 a, float3 b, thread float &rayT) { + float3 seg = b - a; + float3 w0 = ro - a; + float segLen2 = max(dot(seg, seg), 1.0e-5f); + float bDot = dot(rd, seg); + float dDot = dot(rd, w0); + float eDot = dot(seg, w0); + float denom = segLen2 - bDot * bDot; + + float sc = 0.0f; + float tc = 0.0f; + if (abs(denom) > 1.0e-5f) { + sc = clamp((bDot * eDot - segLen2 * dDot) / denom, 0.0f, 10.0f); + tc = clamp((eDot + bDot * sc) / segLen2, 0.0f, 1.0f); + } else { + tc = clamp(eDot / segLen2, 0.0f, 1.0f); + } + + sc = max(dot(a + seg * tc - ro, rd), 0.0f); + float3 closestRay = ro + rd * sc; + float3 closestSeg = a + seg * tc; + rayT = sc; + return length(closestRay - closestSeg); +} + +static float dlRayPointDistance(float3 ro, float3 rd, float3 p, thread float &rayT) { + rayT = max(dot(p - ro, rd), 0.0f); + float3 closestRay = ro + rd * rayT; + return length(closestRay - p); +} + +// --------------------------------------------------------------------------- +// Vertex shader — positions the cube container in world space. +// --------------------------------------------------------------------------- +vertex DigitalLinesVertexOut digitalLinesVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant DigitalLinesUniforms &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; + + DigitalLinesVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment shader — rebuild the original layered dodecahedron as true 3D +// wireframe geometry anchored to the cube center. Each eye traces through the +// same local-space structure, so stereo comes from real ray/segment distances. +// --------------------------------------------------------------------------- +fragment float4 digitalLinesFragment( + DigitalLinesVertexOut in [[stage_in]], + constant DigitalLinesUniforms &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 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 eye = (camWorld - center) / scale; + float3 surfacePos = (in.worldPos - center) / scale; + float3 rd = normalize(surfacePos - eye); + + bool insideOuter = all(abs(eye) < DL_BOX_HALF - 1.0e-3f); + float2 tOuter = dlBoxIntersect(eye, rd, DL_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 ro = eye; + + float3 colorA = float3(1.1f, 0.2f, 0.0f); // _ColorA + float3 colorB = float3(1.0f, 1.2f, 0.5f); // _ColorB + float3 colorC = float3(0.0f, 0.8f, 1.2f); // _ColorC + float globalTime = -uniforms.time * 0.3f; + + float3 col = float3(0.0f); + + for (int i = 0; i < 8; i++) { + float fi = float(i); + float layerProgress = fract((fi / 8.0f) - fract(globalTime)); + float layerScale = pow(2.1f, layerProgress * 2.6f) * 0.14f; + float mask = sin(layerProgress * 3.14159265f); + + float3 layerCol = dlLerp3(colorA, colorB, colorC, layerProgress); + + float3x3 transform = dlRotX(uniforms.time * 0.3f + fi) * dlRotY(uniforms.time * 0.2f); + float lineWidth = mix(0.018f, 0.006f, layerProgress); + + for (int n = 0; n < 30; n++) { + float3 p1 = transform * (DL_VERTS[DL_EDGES[n * 2 ]] * layerScale); + float3 p2 = transform * (DL_VERTS[DL_EDGES[n * 2 + 1]] * layerScale); + + float rayT = 0.0f; + float d = dlRaySegmentDistance(ro, rd, p1, p2, rayT); + if (rayT < tStart) { + continue; + } + float line = smoothstep(lineWidth, 0.0f, d); + float depthFade = exp(-0.28f * rayT); + col += layerCol * line * mask * depthFade; + + if (n < 20) { + float3 pStar = transform * (DL_VERTS[n] * layerScale); + float starT = 0.0f; + float dStar = dlRayPointDistance(ro, rd, pStar, starT); + if (starT < tStart) { + continue; + } + float sparkle = sin(uniforms.time * 10.0f + fi) * 0.5f + 0.5f; + float star = smoothstep(0.03f, 0.0f, dStar); + col += layerCol * star * mask * sparkle * exp(-0.22f * starT) * 0.55f; + } + } + } + + float3 glowCol = mix(colorA, colorC, sin(uniforms.time) * 0.5f + 0.5f); + float centerT = max(dot(-ro, rd), 0.0f); + float centerDist = length(ro + rd * centerT); + col += glowCol * (1.5f * 0.008f / (centerDist + 0.1f)) * exp(-0.35f * centerT); + + col = tanh(col); + return float4(col, 1.0f); +} diff --git a/vr-dive/Demos/DigitalLines/DigitalLinesTypes.swift b/vr-dive/Demos/DigitalLines/DigitalLinesTypes.swift new file mode 100644 index 0000000..ba618ea --- /dev/null +++ b/vr-dive/Demos/DigitalLines/DigitalLinesTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct DigitalLinesUniforms in DigitalLinesShaders.metal. +struct DigitalLinesUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/DirtBall/DirtBallRenderer.swift b/vr-dive/Demos/DirtBall/DirtBallRenderer.swift new file mode 100644 index 0000000..cc3b82d --- /dev/null +++ b/vr-dive/Demos/DirtBall/DirtBallRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// DirtBallRenderer.swift +// +// Cube-container adaptation of ShaderToy "Dirt Ball" (MsVcRy). +// 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 DirtBallRenderer: VisualPatternController { + let identifier: VisualPatternKind = .dirtBall + 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 = DirtBallRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try DirtBallRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = DirtBallRenderer.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.4 + } + + 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 = DirtBallUniforms( + 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 DirtBallRenderer { + 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: "dirtBallVertex") + desc.fragmentFunction = library.makeFunction(name: "dirtBallFragment") + 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/DirtBall/DirtBallShaders.metal b/vr-dive/Demos/DirtBall/DirtBallShaders.metal new file mode 100644 index 0000000..d097e00 --- /dev/null +++ b/vr-dive/Demos/DirtBall/DirtBallShaders.metal @@ -0,0 +1,416 @@ +// DirtBallShaders.metal +// "Dirt Ball" — cube-container adaptation of ShaderToy "MsVcRy" +// Source: https://www.shadertoy.com/view/MsVcRy +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Source notes: +// - The original shader combines a cut sphere, floor lighting, cloudy sky, +// exterior glow, and an internal fractal volume. +// - This version keeps those scene components, but replaces the synthetic orbit +// camera with a real per-eye world ray entering a visible 2 m cube container. +// - The dirt-ball scene is evaluated in its own scene space and is not clipped +// by the cube bounds. + +#include +using namespace metal; + +struct DirtBallUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct DirtBallVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct Scene { + float t; + float id; + float3 n; + float stn; + float stf; +}; + +static constant float DB_FAR = 20.0f; +static constant float DB_EPS = 0.005f; +static constant float DB_SPHERE_EXTERIOR = 1.0f; +static constant float DB_SPHERE_INTERIOR = 2.0f; +static constant float DB_FLOOR = 3.0f; +static constant float DB_SR = 0.2f; +static constant float3 DB_CA = float3(0.5f); +static constant float3 DB_CB = float3(0.5f); +static constant float3 DB_CC = float3(1.0f); +static constant float3 DB_CD = float3(0.0f, 0.33f, 0.67f); +static constant float3 DB_BOX_HALF = float3(1.0f); +static constant float4 DB_SPHERE = float4(0.0f, 0.0f, 0.0f, 1.0f); + +vertex DirtBallVertexOut dirtBallVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant DirtBallUniforms &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; + + DirtBallVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 dbRotate(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 palette(float t, float3 a, float3 b, float3 c, float3 d) { + return a + b * cos(6.28318f * (c * t + d)); +} + +static float3 glowColour(float time) { + return palette(time * 0.1f, DB_CA, DB_CB, DB_CC, DB_CD); +} + +static float2 csqr(float2 a) { + return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y); +} + +static float noise(float3 rp) { + float3 ip = floor(rp); + rp -= ip; + float3 s = float3(7.0f, 157.0f, 113.0f); + float4 h = float4(0.0f, s.y, s.z, s.y + s.z) + dot(ip, s); + rp = rp * rp * (3.0f - 2.0f * rp); + h = mix(fract(sin(h) * 43758.5f), fract(sin(h + s.x) * 43758.5f), rp.x); + h.xy = mix(h.xz, h.yw, rp.y); + return mix(h.x, h.y, rp.z); +} + +static float fbm(float3 x) { + float r = 0.0f; + float w = 1.0f; + float s = 1.0f; + for (int i = 0; i < 5; ++i) { + w *= 0.5f; + s *= 2.0f; + r += w * noise(s * x); + } + return r; +} + +static float tex(float3 rp, float time) { + rp.xy = dbRotate(rp.xy, time); + if (rp.x > 0.3f && rp.x < 0.5f) { + return 0.0f; + } + return 1.0f; +} + +static float pattern(float3 rp, float time) { + float3 f = abs(rp); + f = step(f.zxy, f) * step(f.yzx, f); + float2 face = f.x > 0.5f ? rp.yz / max(rp.x, 1.0e-4f) + : f.y > 0.5f ? rp.xz / max(rp.y, 1.0e-4f) + : rp.xy / max(rp.z, 1.0e-4f); + return tex(float3(face, 0.0f), time); +} + +static float4 sphIntersect(float3 ro, float3 rd, float4 sph, float time) { + 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 float4(0.0f); + } + h = sqrt(h); + float tN = -b - h; + float tNF = tN; + if (pattern(ro + rd * tNF, time) == 0.0f) { + tNF = 0.0f; + } + float tF = -b + h; + float tFF = tF; + if (pattern(ro + rd * tFF, time) == 0.0f) { + tFF = 0.0f; + } + return float4(tNF, tFF, tN, tF); +} + +static float3 sphNormal(float3 pos, float4 sph) { + return normalize(pos - sph.xyz); +} + +static float sphSoftShadow(float3 ro, float3 rd, float4 sph, float k, float time) { + float3 oc = ro - sph.xyz; + float b = dot(oc, rd); + float c = dot(oc, oc) - sph.w * sph.w; + float h = b * b - c; + float d = sqrt(max(0.0f, sph.w * sph.w - h)) - sph.w; + float tN = -b - sqrt(max(h, 0.0f)); + float tF = -b + sqrt(max(h, 0.0f)); + if ((pattern(ro + rd * tN, time) + pattern(ro + rd * tF, time)) == 0.0f) { + return 1.0f; + } + if (tN > 0.0f) { + return smoothstep(0.0f, 1.0f, 4.0f * k * d / tN); + } + return 1.0f; +} + +static float sphOcclusion(float3 pos, float3 nor, float4 sph) { + float3 r = sph.xyz - pos; + float l = length(r); + float d = dot(nor, r); + float res = d; + if (d < sph.w) { + res = pow(clamp((d + sph.w) / (2.0f * sph.w), 0.0f, 1.0f), 1.5f) * sph.w; + } + return clamp(res * (sph.w * sph.w) / (l * l * l), 0.0f, 1.0f); +} + +static float planeIntersection(float3 ro, float3 rd, float3 n, float3 o) { + return dot(o - ro, n) / dot(rd, n); +} + +static float mapVolume(float3 rp) { + return min(length(rp) - DB_SPHERE.w, rp.y + 1.0f); +} + +static float fractal(float3 rp, float time) { + float res = 0.0f; + float x = 0.8f + sin(time * 0.2f) * 0.3f; + rp.yz = dbRotate(rp.yz, time); + float3 c = rp; + for (int i = 0; i < 10; ++i) { + rp = x * abs(rp) / max(dot(rp, rp), 1.0e-4f) - x; + rp.yz = csqr(rp.yz); + rp = rp.zxy; + res += exp(-99.0f * abs(dot(rp, c))); + } + return res; +} + +static float3 fractalMarch(float3 ro, float3 rd, float maxt, float time) { + float3 pc = float3(0.0f); + float t = 0.0f; + float ns = 0.0f; + for (int i = 0; i < 64; ++i) { + float3 rp = ro + t * rd; + float lt = length(rp) - DB_SR; + ns = fractal(rp, time); + if (lt < DB_EPS || t > maxt) { + break; + } + t += 0.02f * exp(-2.0f * ns); + float3 glow = glowColour(time); + pc = 0.99f * (pc + 0.08f * glow * ns) / (1.0f + lt * lt); + pc += 0.1f * glow / (1.0f + lt * lt); + } + return pc; +} + +static float3 vMarch(float3 ro, float3 rd, float time) { + float3 pc = float3(0.0f); + float t = 0.0f; + for (int i = 0; i < 96; ++i) { + float3 rp = ro + rd * t; + float ns = mapVolume(rp); + float fz = pattern(rp, time); + if ((ns < DB_EPS && fz > 0.0f) || t > DB_FAR) { + break; + } + + float3 ld = normalize(-rp); + float lt = length(rp); + if (sphIntersect(rp, ld, DB_SPHERE, time).x == 0.0f || lt < DB_SPHERE.w) { + float ltn = lt - DB_SR; + pc += glowColour(time) * 0.1f / (1.0f + ltn * ltn * 12.0f); + } + t += 0.05f; + } + return pc; +} + +static float3 clouds(float3 rd, float time) { + float ct = time / 14.0f; + float2 uv = rd.xz / (rd.y + 0.6f); + float nz = fbm(float3(uv.yx * 1.4f + float2(ct, 0.0f), ct)) * 1.5f; + return clamp(pow(float3(nz), float3(4.0f)) * rd.y, 0.0f, 1.0f); +} + +static float3 pri(float3 x) { + float3 h = fract(x / 2.0f) - 0.5f; + return x * 0.5f + h * (1.0f - 2.0f * abs(h)); +} + +static float checkersTextureGradTri(float3 p, float3 ddx, float3 ddy, float time) { + p.z += time; + float3 w = max(abs(ddx), abs(ddy)) + 0.01f; + float3 i = (pri(p + w) - 2.0f * pri(p) + pri(p - w)) / (w * w); + return 0.5f - 0.5f * i.x * i.y * i.z; +} + +static float3 texCoords(float3 p) { + return 5.0f * p; +} + +static Scene drawScene(float3 ro, float3 rd, float time) { + Scene scene; + scene.t = DB_FAR; + scene.id = 0.0f; + scene.n = float3(0.0f); + scene.stn = 0.0f; + scene.stf = 0.0f; + + float3 fo = float3(0.0f, -1.0f, 0.0f); + float3 fn = float3(0.0f, 1.0f, 0.0f); + float ft = planeIntersection(ro, rd, fn, fo); + if (ft > 0.0f && ft < DB_FAR) { + scene.t = ft; + scene.id = DB_FLOOR; + scene.n = fn; + } + + float4 si = sphIntersect(ro, rd, DB_SPHERE, time); + if (si.x > 0.0f && si.x < scene.t) { + float3 rp = ro + rd * si.x; + scene.t = si.x; + scene.id = DB_SPHERE_EXTERIOR; + scene.n = sphNormal(rp, DB_SPHERE); + } else if (si.y > 0.0f && si.y < scene.t) { + float3 rp = ro + rd * si.y; + scene.t = si.y; + scene.id = DB_SPHERE_INTERIOR; + scene.n = -sphNormal(rp, DB_SPHERE); + } + + scene.stn = si.z; + scene.stf = si.w; + return scene; +} + +static float2 dbBoxIntersect(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 dbFaceUV(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); +} + +fragment float4 dirtBallFragment( + DirtBallVertexOut in [[stage_in]], + constant DirtBallUniforms &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) < DB_BOX_HALF - 1.0e-3f); + float2 tOuter = dbBoxIntersect(eye, rd, DB_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 ro = (eye + rd * (tStart + 0.001f)) * sceneScale; + float3 marchDir = normalize(rd); + ro.xz = dbRotate(ro.xz, uniforms.time * 0.4f); + marchDir.xz = dbRotate(marchDir.xz, uniforms.time * 0.4f); + + Scene scene = drawScene(ro, marchDir, uniforms.time); + float3 pc = clouds(marchDir, uniforms.time) * glowColour(uniforms.time); + float3 gc = float3(0.0f); + float3 lp = float3(4.0f, 5.0f, -2.0f); + + float3 rp = ro + marchDir * scene.t; + float3 ld = normalize(lp - rp); + float lt = length(lp - rp); + float atten = 1.0f / (1.0f + lt * lt * 0.051f); + + if (scene.stn > 0.0f) { + gc = fractalMarch(ro + marchDir * scene.stn, marchDir, scene.stf - scene.stn, uniforms.time); + pc = gc; + } + + if (scene.id == DB_FLOOR) { + float3 uvw = texCoords(rp * 0.15f); + float3 ddxUVW = dfdx(uvw); + float3 ddyUVW = dfdy(uvw); + float fc = checkersTextureGradTri(uvw, ddxUVW, ddyUVW, uniforms.time); + float diff = max(dot(ld, scene.n), 0.05f); + float ao = 1.0f - sphOcclusion(rp, scene.n, DB_SPHERE); + float spec = pow(max(dot(reflect(-ld, scene.n), -marchDir), 0.0f), 32.0f); + float sh = sphSoftShadow(rp, ld, DB_SPHERE, 2.0f, uniforms.time); + + pc += glowColour(uniforms.time) * fc * diff * atten; + pc += float3(1.0f) * spec; + pc *= ao * sh; + + float3 gld = normalize(-rp); + if (sphIntersect(rp, gld, DB_SPHERE, uniforms.time).x == 0.0f) { + pc += glowColour(uniforms.time) / (1.0f + length(rp) * length(rp)); + } + } + + if (scene.id == DB_SPHERE_EXTERIOR) { + float ao = 0.5f + 0.5f * scene.n.y; + float spec = pow(max(dot(reflect(-ld, scene.n), -marchDir), 0.0f), 32.0f); + float fres = pow(clamp(dot(scene.n, marchDir) + 1.0f, 0.0f, 1.0f), 2.0f); + pc *= 0.4f * (1.0f - fres); + pc += float3(1.0f) * fres * 0.2f; + pc *= ao; + pc += float3(1.0f) * spec; + } + + if (scene.id == DB_SPHERE_INTERIOR) { + float ao = 0.5f + 0.5f * scene.n.y; + float ilt = length(rp) - DB_SR; + pc += glowColour(uniforms.time) * ao / (1.0f + ilt * ilt); + } + + pc += vMarch(ro, marchDir, uniforms.time); + + float2 faceUV = dbFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + return float4(clamp(pc * 2.0f * vignette, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/DirtBall/DirtBallTypes.swift b/vr-dive/Demos/DirtBall/DirtBallTypes.swift new file mode 100644 index 0000000..f9f4189 --- /dev/null +++ b/vr-dive/Demos/DirtBall/DirtBallTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct DirtBallUniforms in DirtBallShaders.metal. +struct DirtBallUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Ether/EtherRenderer.swift b/vr-dive/Demos/Ether/EtherRenderer.swift new file mode 100644 index 0000000..1749528 --- /dev/null +++ b/vr-dive/Demos/Ether/EtherRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// EtherRenderer.swift +// +// Cube-container adaptation of ShaderToy "t3XXWj" 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 EtherRenderer: VisualPatternController { + let identifier: VisualPatternKind = .ether + 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 = EtherRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try EtherRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = EtherRenderer.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 = EtherUniforms( + 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 EtherRenderer { + 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: "etherVertex") + desc.fragmentFunction = library.makeFunction(name: "etherFragment") + 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/Ether/EtherShaders.metal b/vr-dive/Demos/Ether/EtherShaders.metal new file mode 100644 index 0000000..eb31da5 --- /dev/null +++ b/vr-dive/Demos/Ether/EtherShaders.metal @@ -0,0 +1,117 @@ +// EtherShaders.metal +// Adapted from ShaderToy "t3XXWj" by XorDev. +// Source: https://www.shadertoy.com/view/t3XXWj +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Metal adaptation notes: +// - The original shader uses a fixed screen-space camera ray. 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 ether volume is integrated beyond the cube entry plane, so the +// simulated turbulence is not clipped by the container volume. +// - In the original GLSL, the glow accumulation divides by the raymarch step +// size after `d` has been reassigned; this port preserves that order. + +#include +using namespace metal; + +struct EtherUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct EtherVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float3 ETHER_BOX_HALF = float3(1.0f); +static constant float ETHER_EPSILON = 0.002f; +static constant float ETHER_SCENE_SCALE = 4.0f; +static constant int ETHER_TRACE_STEPS = 80; + +vertex EtherVertexOut etherVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant EtherUniforms &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; + + EtherVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 etherBoxIntersect(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 etherFragment( + EtherVertexOut in [[stage_in]], + constant EtherUniforms &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) < ETHER_BOX_HALF - 1.0e-3f); + float2 tBox = etherBoxIntersect(eye, rd, ETHER_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 + ETHER_EPSILON); + + float time = uniforms.time; + float z = 0.0f; + float4 color = float4(0.0f); + + float3 rayOrigin = marchOrigin * ETHER_SCENE_SCALE; + + for (int i = 0; i < ETHER_TRACE_STEPS; ++i) { + float3 p = rayOrigin + rd * z; + p.z -= 5.0f * time; + + for (float frequency = 1.0f; frequency < 15.0f; frequency /= 0.6f) { + p += 0.6f * cos(p.yzx * frequency - float3(time * 0.6f, 0.0f, time)) / frequency; + } + + float stepSize = 0.01f + abs(p.y * 0.3f + dot(cos(p), sin(p.yzx * 0.6f)) + 2.0f) / 3.0f; + z += stepSize; + color += max(sin(z * 0.4f + time + float4(6.0f, 2.0f, 4.0f, 0.0f)) + 0.7f, 0.2f) / stepSize; + } + + color = tanh(color / 2000.0f); + return float4(color.rgb, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Ether/EtherTypes.swift b/vr-dive/Demos/Ether/EtherTypes.swift new file mode 100644 index 0000000..e33d150 --- /dev/null +++ b/vr-dive/Demos/Ether/EtherTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct EtherUniforms in EtherShaders.metal. +struct EtherUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/FiberSpiral/FiberSpiralRenderer.swift b/vr-dive/Demos/FiberSpiral/FiberSpiralRenderer.swift new file mode 100644 index 0000000..59ce1ac --- /dev/null +++ b/vr-dive/Demos/FiberSpiral/FiberSpiralRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// FiberSpiralRenderer.swift +// +// Cube-container adaptation of ShaderToy "XsdSW7" by Stephane Cuillerdier (Aiekick). +// 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 FiberSpiralRenderer: VisualPatternController { + let identifier: VisualPatternKind = .fiberSpiral + 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 = FiberSpiralRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FiberSpiralRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = FiberSpiralRenderer.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 = FiberSpiralUniforms( + 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 FiberSpiralRenderer { + 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: "fiberSpiralVertex") + desc.fragmentFunction = library.makeFunction(name: "fiberSpiralFragment") + 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/FiberSpiral/FiberSpiralShaders.metal b/vr-dive/Demos/FiberSpiral/FiberSpiralShaders.metal new file mode 100644 index 0000000..941fa31 --- /dev/null +++ b/vr-dive/Demos/FiberSpiral/FiberSpiralShaders.metal @@ -0,0 +1,214 @@ +// FiberSpiralShaders.metal +// Adapted from ShaderToy "XsdSW7" by Stephane Cuillerdier (Aiekick), 2015. +// Source: https://www.shadertoy.com/view/XsdSW7 +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Metal adaptation notes: +// - The original shader uses a synthetic camera moving forward along +Z. 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 fractal field is traced beyond the container entry plane, so the +// simulated structure is not clipped by the cube volume. +// - GLSL `mod(p, 4.) - 2.` is expanded with floor-based modulo because Metal's +// `fmod` does not match GLSL's negative-input wrap behaviour. + +#include +using namespace metal; + +struct FiberSpiralUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FiberSpiralVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float3 FS_BOX_HALF = float3(1.0f); +static constant float FS_RATIO = 0.5f; +static constant float FS_SCENE_SCALE = 4.0f; +static constant float FS_TRACE_EPSILON = 0.002f; +static constant float FS_MAX_DIST = 30.0f; +static constant int FS_TRACE_STEPS = 250; + +vertex FiberSpiralVertexOut fiberSpiralVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FiberSpiralUniforms &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; + + FiberSpiralVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 fsPath(float z) { + return float2(cos(z), sin(z)); +} + +static float2 fsRotate(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 fsMod(float3 x, float y) { + return x - y * floor(x / y); +} + +static float fsFractus(float3 p) { + float2 z = p.xy; + float2 c = float2(0.28f, -0.56f) * 2.0f * FS_RATIO; + float k = 1.0f; + float h = 1.0f; + + for (int i = 0; i < 7; ++i) { + h *= 4.0f * k; + k = dot(z, z); + if (k > 4.0f) { + break; + } + z = float2(z.x * z.x - z.y * z.y, 2.0f * z.x * z.y) + c; + } + + float safeK = max(k, 1.0e-5f); + float safeH = max(h, 1.0e-5f); + return sqrt(safeK / safeH) * log(safeK); +} + +static float fsDf(float3 p) { + p.xy += fsPath(p.z * 0.2f) * 1.5f; + p.xy = fsRotate(p.xy, p.z * 0.2f); + p = fsMod(p, 4.0f) - 2.0f; + return fsFractus(p); +} + +static float3 fsNor(float3 p, float prec) { + float2 e = float2(prec, 0.0f); + return normalize(float3( + fsDf(p + e.xyy) - fsDf(p - e.xyy), + fsDf(p + e.yxy) - fsDf(p - e.yxy), + fsDf(p + e.yyx) - fsDf(p - e.yyx))); +} + +static float fsSoftShadow(float3 ro, float3 rd, float mint, float tmax) { + float res = 1.0f; + float t = mint; + for (int i = 0; i < 18; ++i) { + float h = fsDf(ro + rd * t); + res = min(res, 8.0f * h / max(t, 1.0e-3f)); + t += h * 0.25f; + if (h < 0.001f || t > tmax) { + break; + } + } + return clamp(res, 0.0f, 1.0f); +} + +static float fsCao(float3 pos, float3 nor) { + float occ = 0.0f; + float sca = 1.0f; + for (int i = 0; i < 10; ++i) { + float hr = 0.01f + 0.12f * float(i) / 4.0f; + float3 aopos = nor * hr + pos; + float dd = fsDf(aopos); + occ += -(dd - hr) * sca; + sca *= 0.95f; + } + return clamp(1.0f - 3.0f * occ, 0.0f, 1.0f); +} + +static float3 fsLighting(float3 p, float3 lp, float3 rd, float prec) { + float3 l = lp - p; + float dist = max(length(l), 0.01f); + float atten = exp(-0.0001f * dist) - 0.5f; + l /= dist; + + float3 n = fsNor(p, prec); + float3 r = reflect(-l, n); + float dif = clamp(dot(l, n), 0.0f, 1.0f); + float spe = pow(clamp(dot(r, -rd), 0.0f, 1.0f), 8.0f); + float fre = pow(clamp(1.0f + dot(n, rd), 0.0f, 1.0f), 2.0f); + float dom = smoothstep(-1.0f, 1.0f, r.y); + + dif *= fsSoftShadow(p, l, 0.01f, 1.0f); + + float3 lin = float3(0.08f, 0.32f, 0.47f) * fsCao(p, n); + lin += dif * float3(1.0f, 1.0f, 0.84f); + lin += 2.5f * spe * dif * float3(1.0f, 1.0f, 0.84f); + lin += 2.5f * fre * float3(1.0f); + lin += 0.5f * dom * float3(1.0f); + + return lin * atten * fsCao(p, n); +} + +static float2 fsBoxIntersect(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 fiberSpiralFragment( + FiberSpiralVertexOut in [[stage_in]], + constant FiberSpiralUniforms &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) < FS_BOX_HALF - 1.0e-3f); + float2 tBox = fsBoxIntersect(eye, rd, FS_BOX_HALF); + if (!insideBox && tBox.x > tBox.y) { + discard_fragment(); + } + + float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f); + float time = uniforms.time * 5.0f; + float3 ro = (eye + rd * (tStart + FS_TRACE_EPSILON)) * FS_SCENE_SCALE + float3(0.0f, 0.0f, time); + + float d = 0.0f; + float s = 0.01f; + float3 p = ro; + for (int i = 0; i < FS_TRACE_STEPS; ++i) { + if (s < 0.0025f * d || d > FS_MAX_DIST) { + break; + } + s = fsDf(p); + d += s * 0.2f; + p = ro + rd * d; + } + + float3 color = float3(0.47f, 0.6f, 0.76f) * fsLighting(p, ro, rd, 0.1f); + color = mix(color, float3(0.5f, 0.49f, 0.72f), 1.0f - exp(-0.01f * d * d)); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/FiberSpiral/FiberSpiralTypes.swift b/vr-dive/Demos/FiberSpiral/FiberSpiralTypes.swift new file mode 100644 index 0000000..695346d --- /dev/null +++ b/vr-dive/Demos/FiberSpiral/FiberSpiralTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct FiberSpiralUniforms in FiberSpiralShaders.metal. +struct FiberSpiralUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/FireTornado/FireTornadoRenderer.swift b/vr-dive/Demos/FireTornado/FireTornadoRenderer.swift new file mode 100644 index 0000000..c47429b --- /dev/null +++ b/vr-dive/Demos/FireTornado/FireTornadoRenderer.swift @@ -0,0 +1,169 @@ +import Metal +import simd + +// FireTornadoRenderer.swift +// "Fire Tornado" — cube-portal adaptation of Shadertoy "wfSBzV" +// Original: https://www.shadertoy.com/view/wfSBzV + +final class FireTornadoRenderer: VisualPatternController { + let identifier: VisualPatternKind = .fireTornado + 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 = FireTornadoRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FireTornadoRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = FireTornadoRenderer.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 = FireTornadoUniforms( + 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 FireTornadoRenderer { + 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: "fireTornadoVertex") + desc.fragmentFunction = library.makeFunction(name: "fireTornadoFragment") + 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/FireTornado/FireTornadoShaders.metal b/vr-dive/Demos/FireTornado/FireTornadoShaders.metal new file mode 100644 index 0000000..f22a7f7 --- /dev/null +++ b/vr-dive/Demos/FireTornado/FireTornadoShaders.metal @@ -0,0 +1,189 @@ +// FireTornadoShaders.metal +// "Fire Tornado" — cube-portal adaptation of Shadertoy "wfSBzV" +// Original: https://www.shadertoy.com/view/wfSBzV +// +// Metal adaptation notes: +// - The original shader raymarches a fire volume from a fixed screen-space +// camera at z = -10. +// - This version replaces that camera with the real per-eye world ray. When the +// viewer is outside the cube, marching starts at the visible cube surface. +// When the viewer is inside the cube, marching starts at the eye. +// - The fire volume is fixed in scene space around the cube centre and is not +// clipped to the cube bounds, so user motion produces real stereo and spatial +// parallax instead of a 2D image on the container wall. + +#include +using namespace metal; + +struct FireTornadoUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FireTornadoVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct FireTornadoSample { + float dist; + float3 glow; +}; + +static constant float FT_EPSILON = 1.0e-6f; +static constant float3 FT_BG_LOW = float3(0.02f, 0.01f, 0.005f); +static constant float3 FT_BG_HIGH = float3(0.12f, 0.04f, 0.01f); + +vertex FireTornadoVertexOut fireTornadoVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FireTornadoUniforms &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; + + FireTornadoVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 ftRotate(float2 p, float a) { + float s = sin(a); + float c = cos(a); + return float2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +static float ftFbm(float3 p, float time) { + float amp = 1.0f; + float fre = 1.0f; + float n = 0.0f; + for (int i = 0; i < 4; ++i) { + n += abs(dot(cos(p * fre), float3(0.1f, 0.2f, 0.3f))) * amp; + amp *= 0.9f; + fre *= 1.3f; + p.xz = ftRotate(p.xz, p.y * 0.1f + time * 0.3f); + p.y -= time * 4.0f; + } + return n; +} + +static float ftSdBox(float3 p, float3 b) { + float3 q = abs(p) - b; + return length(max(q, 0.0f)) + min(max(q.x, max(q.y, q.z)), 0.0f); +} + +static float ftBoxHit(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 FireTornadoSample ftFireBall(float3 p, float time) { + p.y += 1.0f; + float3 q = p; + + float h = 5.0f; + float range = smoothstep(-h, h, p.y); + float w = range * 4.0f + 1.0f; + float thick = range * 4.0f + 1.0f; + q.xz = ftRotate(q.xz, q.y - time * 2.0f); + + float d = ftSdBox(q, float3(w, h, thick)); + float d1 = ftSdBox(q - float3(0.0f, 1.0f, 0.0f), float3(w, h, thick) * float3(0.7f, 2.0f, 0.7f)); + d = max(d, -d1); + d += ftFbm(p * 3.0f, time) * 0.5f; + d = abs(d) * 0.1f + 0.01f; + + float3 phase = float3(3.0f, 2.0f, 1.0f) + (p.y + p.z) * 0.5f - time * 2.0f; + float3 c = sin(phase) * 0.5f + 0.5f; + + FireTornadoSample result; + result.dist = d; + result.glow = pow(1.3f / max(d, 1.0e-4f), 2.0f) * c; + return result; +} + +static float3 ftBackground(float3 rd) { + float t = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float horizon = pow(1.0f - abs(rd.z), 3.0f); + return mix(FT_BG_LOW, FT_BG_HIGH, t) + horizon * float3(0.08f, 0.02f, 0.0f); +} + +fragment float4 fireTornadoFragment( + FireTornadoVertexOut in [[stage_in]], + constant FireTornadoUniforms &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 : ftBoxHit(eye, rd, float3(1.0f), faceNormal, true); + if (!insideBox && entryT < 0.0f) { + discard_fragment(); + } + + float3 marchOrigin = insideBox ? (eye + rd * 0.002f) : (eye + rd * (entryT + 0.002f)); + + // Shrink the authored scene so the tornado fits the 2 m cube more naturally, + // while still allowing the effect to extend beyond the cube without clipping. + float fireScale = 10.0f; + float maxDistance = 18.0f; + float travel = 0.1f; + float3 color = float3(0.0f); + + for (int i = 0; i < 100; ++i) { + if (travel > maxDistance) { + break; + } + + float3 worldPoint = marchOrigin + rd * travel; + FireTornadoSample sample = ftFireBall(worldPoint * fireScale, uniforms.time); + float dist = sample.dist / fireScale; + color += sample.glow; + + if (dist < FT_EPSILON) { + break; + } + travel += dist; + } + + color = tanh(color / 9.0e4f); + color += ftBackground(rd) * (1.0f - clamp(length(color) * 1.8f, 0.0f, 1.0f)); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/FireTornado/FireTornadoTypes.swift b/vr-dive/Demos/FireTornado/FireTornadoTypes.swift new file mode 100644 index 0000000..15d0672 --- /dev/null +++ b/vr-dive/Demos/FireTornado/FireTornadoTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct FireTornadoUniforms in FireTornadoShaders.metal. +struct FireTornadoUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Floreus/FloreusRenderer.swift b/vr-dive/Demos/Floreus/FloreusRenderer.swift new file mode 100644 index 0000000..10a8708 --- /dev/null +++ b/vr-dive/Demos/Floreus/FloreusRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// FloreusRenderer.swift +// +// Cube-container adaptation of ShaderToy "Floreus" (33fyWB). +// 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 FloreusRenderer: VisualPatternController { + let identifier: VisualPatternKind = .floreus + 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 = FloreusRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FloreusRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = FloreusRenderer.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 = FloreusUniforms( + 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 FloreusRenderer { + 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: "floreusVertex") + desc.fragmentFunction = library.makeFunction(name: "floreusFragment") + 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/Floreus/FloreusShaders.metal b/vr-dive/Demos/Floreus/FloreusShaders.metal new file mode 100644 index 0000000..ad245d0 --- /dev/null +++ b/vr-dive/Demos/Floreus/FloreusShaders.metal @@ -0,0 +1,287 @@ +// FloreusShaders.metal +// Adapted from ShaderToy "Floreus" by Jaenam. +// Source: https://www.shadertoy.com/view/33fyWB +// License: Creative Commons Attribution-NonCommercial-ShareAlike 4.0. +// +// Metal adaptation notes: +// - The original shader is a compact forward ray accumulator defined in screen +// space. This version evaluates the same iterative field along the real per- +// eye world ray after intersecting a 2 m cube container. +// - Outside the cube, marching starts at the visible cube surface; inside the +// cube, marching starts at the eye. +// - The fractal accumulation continues beyond the entry plane, so the visual +// field is not clipped to the cube volume. +// - GLSL macro rotations and implicit initialization tricks are expanded into +// explicit Metal helpers and explicit initial values. + +#include +using namespace metal; + +struct FloreusUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FloreusVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct FlField { + float sdf; + float density; + float3 tint; +}; + +static constant float3 FL_BOX_HALF = float3(1.0f); +static constant float FL_TRACE_EPSILON = 0.0015f; +static constant int FL_STEPS = 72; +static constant int FL_DETAIL_STEPS = 3; +static constant float FL_MAX_DIST = 4.8f; +static constant float FL_MIN_STEP = 0.012f; +static constant float FL_MAX_STEP = 0.085f; +static constant float3 FL_SATELLITE_CENTERS[4] = { + float3(0.82f, 0.0f, 0.12f), + float3(-0.86f, 0.05f, -0.06f), + float3(0.1f, 0.84f, 0.18f), + float3(-0.14f, -0.88f, 0.16f) +}; +static constant float3 FL_SATELLITE_AXES[4] = { + float3(1.0f, 0.0f, 0.14f), + float3(-1.0f, 0.06f, -0.1f), + float3(0.12f, 1.0f, 0.18f), + float3(-0.15f, -1.0f, 0.16f) +}; +static constant float FL_SATELLITE_SIZES[4] = {0.24f, 0.25f, 0.22f, 0.22f}; +static constant float FL_SATELLITE_HUES[4] = {0.45f, 0.95f, 1.45f, 1.95f}; + +vertex FloreusVertexOut floreusVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FloreusUniforms &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; + + FloreusVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 flRotate2D(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 float2 flFaceUV(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 flBoxIntersect(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 flCapsule(float3 p, float3 a, float3 b, float r) { + float3 pa = p - a; + float3 ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0f, 1.0f); + return length(pa - ba * h) - r; +} + +static float3 flToLocal(float3 p, float3 axis) { + float3 forward = normalize(axis); + float3 upRef = abs(forward.y) > 0.95f ? float3(1.0f, 0.0f, 0.0f) : float3(0.0f, 1.0f, 0.0f); + float3 right = normalize(cross(upRef, forward)); + float3 up = cross(forward, right); + return float3(dot(p, right), dot(p, up), dot(p, forward)); +} + +static float flPetalShell(float3 p, float petals, float radius, float thickness, float curl) { + float angle = atan2(p.y, p.x); + float radial = length(p.xy); + float lobe = radius * (0.56f + 0.44f * cos(petals * angle)); + float bow = p.z + curl * smoothstep(0.0f, radius + 0.3f, radial) * + (0.4f + 0.6f * abs(cos(angle * petals * 0.5f))); + return length(float2(radial - lobe, bow * 1.75f)) - thickness; +} + +static float flDetailField(float3 p, float time) { + p.xz = flRotate2D(p.xz, 0.18f * time); + float response = 0.0f; + float weight = 1.0f; + for (int inner = 0; inner < FL_DETAIL_STEPS; ++inner) { + float2 folded = min(abs(p.xz), abs(p.xy)); + float l = length(float2(0.65f) - folded) / max(dot(p, p + p), 0.3f); + p = sin(p * 1.25f); + p *= l; + response += weight * exp(-2.2f * length(p)); + weight *= 0.55f; + } + return response; +} + +static void flAccumulate(thread FlField &field, float sdf, float density, float3 tint) { + if (sdf < field.sdf) { + field.sdf = sdf; + field.tint = tint; + } + field.density += density; +} + +static void flAddBloom( + thread FlField &field, + float3 p, + float3 center, + float3 axis, + float size, + float time, + float hueShift +) { + float3 local = flToLocal(p - center, axis) / size; + + float3 petalA = local; + petalA.xy = flRotate2D(petalA.xy, hueShift + time * 0.08f); + float shellA = flPetalShell(petalA, 6.0f, 0.62f, 0.06f, 0.16f); + + float3 petalB = local; + petalB.xy = flRotate2D(petalB.xy, 1.0472f + hueShift * 0.6f); + petalB.yz = flRotate2D(petalB.yz, 0.85f); + float shellB = flPetalShell(petalB, 4.0f, 0.34f, 0.038f, -0.1f); + + float core = length(local) - 0.14f; + float sdf = min(min(shellA, shellB), core) * size; + + float density = + 0.82f * exp(-12.0f * abs(shellA)) + + 0.46f * exp(-16.0f * abs(shellB)) + + 0.34f * exp(-18.0f * abs(core)); + + float shimmer = 0.5f + 0.5f * sin(hueShift * 3.0f + time * 0.45f); + float3 tint = mix(float3(1.1f, 0.72f, 0.25f), float3(1.85f, 1.55f, 1.08f), shimmer); + flAccumulate(field, sdf, density, tint); +} + +static void flAddBranch( + thread FlField &field, + float3 p, + float3 a, + float3 b, + float time, + float thickness, + float hueShift +) { + float sdf = flCapsule(p, a, b, thickness * (0.9f + 0.1f * sin(time * 0.6f + hueShift))); + float wave = 0.5f + 0.5f * sin(dot(normalize(b - a), p) * 13.0f + time * 1.4f + hueShift); + float density = exp(-16.0f * abs(sdf)) * (0.22f + 0.28f * wave); + float3 tint = mix(float3(0.82f, 0.55f, 0.18f), float3(1.35f, 1.08f, 0.62f), wave); + flAccumulate(field, sdf, density, tint); +} + +static FlField flMap(float3 p, float time) { + FlField field; + field.sdf = 1.0e9f; + field.density = 0.0f; + field.tint = float3(1.0f, 0.85f, 0.5f); + + float3 q = p; + q.xz = flRotate2D(q.xz, time * 0.08f); + q.yz = flRotate2D(q.yz, -time * 0.05f); + + flAddBloom(field, q, float3(0.0f, 0.0f, 0.0f), float3(0.0f, 0.0f, 1.0f), 0.5f, time, 0.0f); + + for (int i = 0; i < 4; ++i) { + flAddBloom(field, q, FL_SATELLITE_CENTERS[i], FL_SATELLITE_AXES[i], FL_SATELLITE_SIZES[i], time, FL_SATELLITE_HUES[i]); + flAddBranch(field, q, float3(0.0f), FL_SATELLITE_CENTERS[i] * 0.82f, time, 0.028f, FL_SATELLITE_HUES[i]); + } + + field.density += flDetailField(q * 1.45f, time) * (0.08f + 0.14f * exp(-3.0f * abs(field.sdf))); + field.density = min(field.density, 1.8f); + return field; +} + +fragment float4 floreusFragment( + FloreusVertexOut in [[stage_in]], + constant FloreusUniforms &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) < FL_BOX_HALF - 1.0e-3f); + float2 tBox = flBoxIntersect(eye, rd, FL_BOX_HALF); + if (!insideBox && tBox.x > tBox.y) { + discard_fragment(); + } + + float tStart = insideBox ? 0.0f : max(tBox.x, 0.0f); + float3 entry = eye + rd * (tStart + FL_TRACE_EPSILON); + float3 ro = entry * 1.05f; + float3 marchDir = normalize(rd); + + float3 accum = float3(0.0f); + float transmittance = 1.0f; + float travel = 0.0f; + float time = uniforms.time; + + for (int step = 0; step < FL_STEPS; ++step) { + float3 pos = ro + marchDir * travel; + FlField field = flMap(pos, time); + + float density = field.density; + accum += transmittance * field.tint * density * 0.05f; + + transmittance *= exp(-density * 0.09f); + if (transmittance < 0.02f || travel > FL_MAX_DIST) { + break; + } + + float stepMix = clamp(abs(field.sdf) * 1.6f, 0.0f, 1.0f); + travel += mix(FL_MIN_STEP, FL_MAX_STEP, stepMix); + } + + float3 color = 1.0f - exp(-accum * 1.15f); + color = pow(color, float3(0.94f)); + + float2 q = flFaceUV(hit); + float vignette = 1.0f - 0.18f * dot(q * 2.0f - 1.0f, q * 2.0f - 1.0f); + color *= vignette; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Floreus/FloreusTypes.swift b/vr-dive/Demos/Floreus/FloreusTypes.swift new file mode 100644 index 0000000..db72356 --- /dev/null +++ b/vr-dive/Demos/Floreus/FloreusTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct FloreusUniforms in FloreusShaders.metal. +struct FloreusUniforms { + 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/FlowerTest/FlowerTestRenderer.swift b/vr-dive/Demos/FlowerTest/FlowerTestRenderer.swift new file mode 100644 index 0000000..f366ba5 --- /dev/null +++ b/vr-dive/Demos/FlowerTest/FlowerTestRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// FlowerTestRenderer.swift +// +// Cube-container adaptation of ShaderToy "Flower Test" (MltSRf). +// 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 FlowerTestRenderer: VisualPatternController { + let identifier: VisualPatternKind = .flowerTest + 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 = FlowerTestRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FlowerTestRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = FlowerTestRenderer.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 = FlowerTestUniforms( + 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 FlowerTestRenderer { + 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: "flowerTestVertex") + desc.fragmentFunction = library.makeFunction(name: "flowerTestFragment") + 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/FlowerTest/FlowerTestShaders.metal b/vr-dive/Demos/FlowerTest/FlowerTestShaders.metal new file mode 100644 index 0000000..cb1e033 --- /dev/null +++ b/vr-dive/Demos/FlowerTest/FlowerTestShaders.metal @@ -0,0 +1,247 @@ +// FlowerTestShaders.metal +// Adapted from ShaderToy "Flower Test" by inigo quilez, 2013. +// Source: https://www.shadertoy.com/view/MltSRf +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Metal adaptation notes: +// - The original shader constructed a synthetic orbit camera. This version +// reconstructs the real per-eye world ray, intersects it with a 2 m cube +// container, and starts marching at the visible cube surface or at the eye +// when the viewer is inside the cube. +// - The flower SDF is evaluated beyond the cube entry plane, so the simulated +// bloom is not clipped to the container volume. +// - Unused GLSL primitive helpers were omitted; the port keeps the operators and +// distance logic that actually drive the original flower and shading path. + +#include +using namespace metal; + +struct FlowerTestUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FlowerTestVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct FTRayHit { + float t; + float material; +}; + +static constant float3 FT_BOX_HALF = float3(1.0f); +static constant float FT_TRACE_EPSILON = 0.0015f; +static constant float FT_SCENE_SCALE = 2.3f; +static constant float FT_PRECIS = 0.06f; +static constant float FT_TMIN = 0.0f; +static constant float FT_TMAX = 20.0f; +static constant float3 FT_SKY_A = float3(0.7f, 0.9f, 1.0f); +static constant float3 FT_LIGHT_DIR = float3(-0.57207757f, 0.66742384f, -0.4767313f); + +vertex FlowerTestVertexOut flowerTestVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FlowerTestUniforms &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; + + FlowerTestVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 ftRotate2D(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 float2 ftFaceUV(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 ftBoxIntersect(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 ftFlower(float3 p, float r, float time) { + float q = length(p); + p -= float3( + sin(p.x * 15.1f), + sin(p.y * 25.1f), + sin(p.z * 15.0f)) * 0.01f; + + float3 n = normalize(p); + q = length(p); + + float rho = atan2(length(float2(n.x, n.z)), n.y) * 20.0f + q * 15.01f; + float theta = atan2(n.x, n.z) * 6.0f + p.y * 3.0f + rho * 1.50f; + float poleMask = 1.3f - abs(dot(n, float3(0.0f, 1.0f, 0.0f))); + + return length(p) - ( + r + + sin(theta) * 0.3f * poleMask + + sin(rho - time * 2.0f) * 0.3f * poleMask); +} + +static float2 ftMap(float3 pos, float time) { + return float2(ftFlower(pos, 0.750f, time), 15.1f); +} + +static FTRayHit ftCastRay(float3 ro, float3 rd, float time) { + float t = FT_TMIN; + float material = -1.0f; + + for (int i = 0; i < 400; ++i) { + float2 res = ftMap(ro + rd * t, time); + if (res.x < FT_PRECIS || t > FT_TMAX) { + material = res.y; + break; + } + t += res.x * 0.05f; + } + + if (t > FT_TMAX) { + material = -1.0f; + } + + FTRayHit hit; + hit.t = t; + hit.material = material; + return hit; +} + +static float3 ftCalcNormal(float3 pos, float time) { + float3 eps = float3(0.001f, 0.0f, 0.0f); + float dx = ftMap(pos + eps.xyy, time).x - ftMap(pos - eps.xyy, time).x; + float dy = ftMap(pos + eps.yxy, time).x - ftMap(pos - eps.yxy, time).x; + float dz = ftMap(pos + eps.yyx, time).x - ftMap(pos - eps.yyx, time).x; + return normalize(float3(dx, dy, dz)); +} + +static float ftCalcAO(float3 pos, float3 nor, float time) { + float occ = 0.0f; + float sca = 1.0f; + for (int i = 0; i < 15; ++i) { + float hr = 0.05f + 0.12f * float(i) / 4.0f; + float3 aopos = nor * hr + pos; + float dd = ftMap(aopos, time).x; + occ += -(dd - hr) * sca; + sca *= 0.95f; + } + return clamp(1.0f - 3.0f * occ, 0.0f, 1.0f); +} + +static float3 ftRender(float3 ro, float3 rd, float time) { + float3 col = FT_SKY_A + rd.y * 0.8f; + FTRayHit hit = ftCastRay(ro, rd, time); + + if (hit.material > -0.5f) { + float3 pos = ro + hit.t * rd; + float3 nor = ftCalcNormal(pos, time); + float3 ref = reflect(rd, nor); + + col = 0.50f + 0.3f * sin( + float3(2.3f - pos.y / 2.0f, 2.15f - pos.y / 4.0f, -1.30f) * (hit.material - 1.0f)); + + if (hit.material < 1.5f) { + float checker = fmod(floor(5.0f * pos.z) + floor(5.0f * pos.x), 2.0f); + col = 0.4f + 0.1f * checker * float3(1.0f); + } + + float occ = ftCalcAO(pos, nor, time); + float amb = 0.0f; + float dif = clamp(dot(nor, FT_LIGHT_DIR), 0.0f, 1.0f); + float bac = 0.0f; + float dom = smoothstep(-0.1f, 0.1f, ref.y); + float fre = 0.750f; + float spe = 0.0f; + + float3 lin = float3(0.0f); + lin += 1.20f * dif * float3(1.00f, 0.85f, 0.55f); + lin += 1.20f * spe * float3(1.00f, 0.85f, 0.55f) * dif; + lin += 0.20f * amb * float3(0.50f, 0.70f, 1.00f) * occ; + lin += 0.30f * dom * float3(0.50f, 0.70f, 1.00f) * occ; + lin += 0.30f * bac * float3(0.25f, 0.25f, 0.25f) * occ; + lin += 0.40f * fre * float3(1.00f, 1.00f, 1.00f) * occ; + col *= lin; + col = mix(col, float3(0.8f, 0.9f, 1.0f), 1.0f - exp(-0.002f * hit.t * hit.t)); + } + + return clamp(col, 0.0f, 1.0f); +} + +fragment float4 flowerTestFragment( + FlowerTestVertexOut in [[stage_in]], + constant FlowerTestUniforms &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) < FT_BOX_HALF - 1.0e-3f); + float2 tBox = ftBoxIntersect(eye, rd, FT_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 + FT_TRACE_EPSILON)) * FT_SCENE_SCALE; + + float sceneTime = 15.0f + uniforms.time * 3.0f; + float3 rotatedRo = ro; + float3 rotatedRd = rd; + float spin = -0.3f * sceneTime; + rotatedRo.xz = ftRotate2D(rotatedRo.xz, spin); + rotatedRd.xz = ftRotate2D(rotatedRd.xz, spin); + rotatedRo.xy = ftRotate2D(rotatedRo.xy, 0.2f * sin(sceneTime * 0.17f)); + rotatedRd.xy = ftRotate2D(rotatedRd.xy, 0.2f * sin(sceneTime * 0.17f)); + + float3 col = ftRender(rotatedRo, normalize(rotatedRd), sceneTime); + col = pow(col, float3(0.4545f)); + + float2 q = ftFaceUV(hit); + float vignette = 1.0f - 0.35f * dot(q * 2.0f - 1.0f, q * 2.0f - 1.0f); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/FlowerTest/FlowerTestTypes.swift b/vr-dive/Demos/FlowerTest/FlowerTestTypes.swift new file mode 100644 index 0000000..5be1456 --- /dev/null +++ b/vr-dive/Demos/FlowerTest/FlowerTestTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct FlowerTestUniforms in FlowerTestShaders.metal. +struct FlowerTestUniforms { + 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/FollowYourLight/FollowYourLightRenderer.swift b/vr-dive/Demos/FollowYourLight/FollowYourLightRenderer.swift new file mode 100644 index 0000000..ffc1dd3 --- /dev/null +++ b/vr-dive/Demos/FollowYourLight/FollowYourLightRenderer.swift @@ -0,0 +1,176 @@ +import Metal +import simd + +// FollowYourLightRenderer.swift +// 3D cube-container adaptation of "Follow your light" (ShaderToy 73s3zs). +// Original: https://www.shadertoy.com/view/73s3zs by Noztol + +final class FollowYourLightRenderer: VisualPatternController { + let identifier: VisualPatternKind = .followYourLight + 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 = FollowYourLightRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0)) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FollowYourLightRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = FollowYourLightRenderer.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 = FollowYourLightUniforms( + 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 FollowYourLightRenderer { + 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: "followYourLightVertex") + desc.fragmentFunction = library.makeFunction(name: "followYourLightFragment") + 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/FollowYourLight/FollowYourLightShaders.metal b/vr-dive/Demos/FollowYourLight/FollowYourLightShaders.metal new file mode 100644 index 0000000..708c1cb --- /dev/null +++ b/vr-dive/Demos/FollowYourLight/FollowYourLightShaders.metal @@ -0,0 +1,194 @@ +// FollowYourLightShaders.metal +// 3D visionOS adaptation of "Follow your light" (ShaderToy 73s3zs). +// +// Original GLSL: +// https://www.shadertoy.com/view/73s3zs +// "Follow your light" by Noztol +// Inspired by and rewrite of shadertoy.com/view/WcdczB +// Ported to Metal / visionOS cube-container by the vr-dive project. +// +// Technique: Volumetric accumulation ray march (28 steps) through a winding +// tunnel with a glowing orb, using palette-based color accumulation. +// +// GLSL → Metal translation notes: +// • vec3(12.0 * cos(z * vec2(0.1, 0.12)), z) → float3(12*cos(z*0.1), 12*cos(z*0.12), z) +// (GLSL uses a vec2 element-wise constructor; Metal needs explicit components) +// • animTime + 16.0 * rayPos → float scalar + float3: Metal broadcasts the scalar ✓ +// • length(rayPos.xy - pathCenter.x - 6.0) → Metal broadcasts scalar subtraction ✓ +// • for(float i = 1.0; i <= 28.0; i++) → int loop; cast to float where needed + +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Shared types (must match FollowYourLightTypes.swift) +// --------------------------------------------------------------------------- + +struct FollowYourLightUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FollowYourLightVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Path helper +// --------------------------------------------------------------------------- + +// Winding tunnel centre — GLSL: vec3(12.0 * cos(z * vec2(0.1, 0.12)), z) +// cos applied element-wise to each frequency component +static float3 fylGetPathPosition(float z) { + return float3(12.0f * cos(z * 0.1f), + 12.0f * cos(z * 0.12f), + z); +} + +// Axis-aligned box intersection; returns (tNear, tFar) +static float2 fylBoxIntersect(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)); +} + +// --------------------------------------------------------------------------- +// Vertex +// --------------------------------------------------------------------------- + +vertex FollowYourLightVertexOut followYourLightVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FollowYourLightUniforms &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; + + FollowYourLightVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment — volumetric accumulation ray march +// --------------------------------------------------------------------------- + +fragment float4 followYourLightFragment( + FollowYourLightVertexOut in [[stage_in]], + constant FollowYourLightUniforms &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); // cube local ±1 + + // Camera and surface 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 + bool insideBox = all(abs(eye) < halfExt - 1.0e-3f); + float2 tBox = fylBoxIntersect(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(); } + + // Map cube-local entry point into scene space. + // sceneScale=15 maps cube ±1 → ±15 scene units, matching the original's 30-unit march budget. + // The virtual camera follows the winding tunnel path at animTime. + const float sceneScale = 15.0f; + float animTime = uniforms.time * 4.0f + 5.0f + 5.0f * sin(uniforms.time * 0.3f); + float3 virtualCam = fylGetPathPosition(animTime); + + // Flip z: ShaderToy content runs in +Z; cube local -Z must map to scene +Z. + float3 ro_entry = (eye + viewDir * (tStart + 0.001f)); + float3 ro = float3(ro_entry.x, ro_entry.y, -ro_entry.z) * sceneScale + virtualCam; + float3 rd = float3(viewDir.x, viewDir.y, -viewDir.z); + + // March budget bounded by the box traversal distance (capped at 30 to match original) + float maxTotalDist = min((tEnd - tStart) * sceneScale, 30.0f); + + // ----------------------------------------------------------------------- + // Volumetric accumulation loop (28 steps — matching original) + // ----------------------------------------------------------------------- + float stepDist = 1.0f; + float totalDist = 0.0f; + float orbDist = 1.0f; + float3 accum = float3(0.0f); + float3 rayPos = ro; + + float sineTime = sin(uniforms.time); // original: sineTime = sin(iTime) + + for (int i = 1; i <= 28; i++) { + if (totalDist >= maxTotalDist) break; + + // 1. March ray forward + rayPos += rd * stepDist; + + // 2. Path centre at current z + float3 pathCenter = fylGetPathPosition(rayPos.z); + + // 3. Orb geometry — orb drifts with sineTime, slightly ahead of animTime + float3 orbCenter = float3( + pathCenter.x + sineTime, + pathCenter.y + sineTime * 2.0f, + 6.0f + animTime + sineTime * 2.0f); + orbDist = length(rayPos - orbCenter) - 0.01f; + + // 4. Tunnel wall geometry + float baseRadius = cos(rayPos.z * 0.6f) * 2.0f + 4.0f; + + // Two distance measures combined to make tunnel cross-section irregular + // GLSL: length(rayPos.xy - pathCenter.x - 6.0) + // → Metal: scalar broadcast across float2 ✓ + float tunnelStructure = min( + length(rayPos.xy - pathCenter.x - 6.0f), + length((rayPos - pathCenter).xy)); + + // Scalar broadcast in Metal: float + float3 = float3 ✓ + float largeScoops = abs(dot(sin(0.4f * rayPos), float3(0.25f))) / 0.1f; + float detailTexture = abs(dot(sin(animTime + 16.0f * rayPos), float3(0.22f))) / 2.0f; + + float tunnelDist = baseRadius - tunnelStructure + largeScoops + detailTexture; + + // 5. Adaptive step size + stepDist = min(orbDist, 0.01f + 0.3f * abs(tunnelDist)); + totalDist += stepDist; + + // 6. Colour accumulation — palette cycles per loop index + float fi = float(i); + float3 palette = 1.0f + cos(fi * 0.7f + float3(6.0f, 1.0f, 2.0f)); + accum += (palette / stepDist + 10.0f * palette / max(orbDist, 0.6f)) / fi; + } + + // Tone-map: squash then tanh (matches original) + float3 col = accum * accum / 2000.0f; + return float4(tanh(col), 1.0f); +} diff --git a/vr-dive/Demos/FollowYourLight/FollowYourLightTypes.swift b/vr-dive/Demos/FollowYourLight/FollowYourLightTypes.swift new file mode 100644 index 0000000..3baa1d6 --- /dev/null +++ b/vr-dive/Demos/FollowYourLight/FollowYourLightTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct FollowYourLightUniforms in FollowYourLightShaders.metal. +struct FollowYourLightUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Fractal49Gaz/Fractal49GazRenderer.swift b/vr-dive/Demos/Fractal49Gaz/Fractal49GazRenderer.swift new file mode 100644 index 0000000..5bfbfdc --- /dev/null +++ b/vr-dive/Demos/Fractal49Gaz/Fractal49GazRenderer.swift @@ -0,0 +1,178 @@ +import Metal +import simd + +// Fractal49GazRenderer.swift +// +// Cube-container adaptation of ShaderToy "Fractal 49_gaz" (fdSGzt). +// The visible container is a 2 m × 2 m × 2 m cube. Rays start at the visible +// cube surface when viewed from outside, or at the viewer position when the +// camera is inside the cube. + +final class Fractal49GazRenderer: VisualPatternController { + let identifier: VisualPatternKind = .fractal49Gaz + 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 = Fractal49GazRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try Fractal49GazRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = Fractal49GazRenderer.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.55 + } + + 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 = Fractal49GazUniforms( + 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 Fractal49GazRenderer { + 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: "fractal49GazVertex") + desc.fragmentFunction = library.makeFunction(name: "fractal49GazFragment") + 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/Fractal49Gaz/Fractal49GazShaders.metal b/vr-dive/Demos/Fractal49Gaz/Fractal49GazShaders.metal new file mode 100644 index 0000000..8a6c9d3 --- /dev/null +++ b/vr-dive/Demos/Fractal49Gaz/Fractal49GazShaders.metal @@ -0,0 +1,197 @@ +// Fractal49GazShaders.metal +// "Fractal 49_gaz" — cube-container adaptation of ShaderToy fdSGzt. +// Source: https://www.shadertoy.com/view/fdSGzt +// Original source header notes this shader was forked from Xs3yRM and licensed +// under CC-BY-NC-SA-3.0. This adaptation preserves the core iterative fractal +// accumulation while replacing the synthetic screen camera with a real per-eye +// world ray entering a visible 2 m cube container. + +#include +using namespace metal; + +struct Fractal49GazUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct Fractal49GazVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct FractalAccum { + float3 color; + float glow; +}; + +static constant float3 FG_BOX_HALF = float3(1.0f); +static constant float3 FG_AXIS_BASE = float3(0.26726124f, 0.53452248f, 0.80178373f); +static constant float3 FG_EQ = float3(0.577350269f); + +static float3 hue(float h) { + return cos(h * 6.3f + float3(0.0f, 23.0f, 21.0f)) * 0.5f + 0.5f; +} + +static float3 fgTonemap(float3 color) { + return color / (1.0f + color); +} + +static float3 rotateAroundAxis(float3 p, float3 axis, float angle) { + float c = cos(angle); + float s = sin(angle); + return mix(axis * dot(p, axis), p, c) + s * cross(p, axis); +} + +static float2 fgBoxIntersect(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 fgFaceUV(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 sceneWarp(float3 p, float time) { + float3 axis = normalize(float3(1.0f, 2.0f * sin(time * 0.1f), 3.0f)); + p = rotateAroundAxis(p, axis, time * 0.2f); + p.xz = float2( + cos(time * 0.17f) * p.x + sin(time * 0.17f) * p.z, + -sin(time * 0.17f) * p.x + cos(time * 0.17f) * p.z); + return p; +} + +static FractalAccum traceFractal(float3 ro, float3 rd, float time) { + FractalAccum accum; + accum.color = float3(0.0f); + accum.glow = 0.0f; + + float g = 1.5f; + float lastError = 0.0f; + + for (int stepIndex = 0; stepIndex < 90; ++stepIndex) { + float iteration = float(stepIndex + 1); + float3 p = g * rd - float3(-0.2f, 0.3f, 2.5f); + p += ro; + p = sceneWarp(p, time); + + float s = 5.0f; + p = p / max(dot(p, p), 1.0e-4f) + 1.0f; + float e = 0.0f; + for (int fractalIndex = 0; fractalIndex < 8; ++fractalIndex) { + p = 1.0f - abs(p - 1.0f); + e = 1.6f / min(dot(p, p), 1.5f); + s *= e; + p *= e; + } + + lastError = length(cross(p, FG_EQ)) / s - 5.0e-4f; + float3 tint = mix(float3(1.0f), hue(log(max(s, 1.0e-4f)) * 0.3f), 0.8f); + float falloff = exp(-12.0f * iteration * iteration * max(lastError, 0.0f)); + accum.color += 0.03f * tint * falloff; + accum.glow += min(0.04f, 0.0016f / (0.002f + lastError * lastError)); + + g += lastError; + if (g > 18.0f) { + break; + } + } + + return accum; +} + +vertex Fractal49GazVertexOut fractal49GazVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant Fractal49GazUniforms &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; + + Fractal49GazVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +fragment float4 fractal49GazFragment( + Fractal49GazVertexOut in [[stage_in]], + constant Fractal49GazUniforms &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 cameraWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 eye = (cameraWorld - center) / scale; + float3 surfacePos = (in.worldPos - center) / scale; + float3 rd = normalize(surfacePos - eye); + + bool insideOuter = all(abs(eye) < FG_BOX_HALF - 1.0e-3f); + float2 tOuter = fgBoxIntersect(eye, rd, FG_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 localOrigin = eye + rd * (tStart + 0.001f); + + const float sceneScale = 2.6f; + float3 sceneRo = localOrigin * sceneScale; + float3 sceneRd = normalize(rd); + + FractalAccum accum = traceFractal(sceneRo, sceneRd, uniforms.time); + + float3 axis = normalize(float3(1.0f, 2.0f * sin(uniforms.time * 0.1f), 3.0f)); + float horizon = pow(clamp(1.0f - abs(sceneRd.y), 0.0f, 1.0f), 2.0f); + float facing = pow(clamp(dot(sceneRd, FG_AXIS_BASE) * 0.5f + 0.5f, 0.0f, 1.0f), 3.0f); + float swirl = pow(clamp(dot(sceneRd, axis) * 0.5f + 0.5f, 0.0f, 1.0f), 2.0f); + + float3 background = mix( + float3(0.03f, 0.02f, 0.06f), + float3(0.24f, 0.08f, 0.32f), + horizon); + background += hue(uniforms.time * 0.08f + swirl * 0.25f) * (0.025f + 0.08f * facing); + background += float3(0.14f, 0.28f, 0.1f) * swirl * 0.08f; + + float2 faceUV = fgFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.16f * dot(faceUV, faceUV); + + float glow = min(accum.glow, 6.0f) * 0.02f; + float3 color = background * 0.22f + accum.color; + color += glow * float3(0.45f, 0.82f, 0.52f); + color *= vignette; + + color = fgTonemap(max(color, 0.0f)); + color = pow(color, float3(0.96f)); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Fractal49Gaz/Fractal49GazTypes.swift b/vr-dive/Demos/Fractal49Gaz/Fractal49GazTypes.swift new file mode 100644 index 0000000..7e22b64 --- /dev/null +++ b/vr-dive/Demos/Fractal49Gaz/Fractal49GazTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct Fractal49GazUniforms in Fractal49GazShaders.metal. +struct Fractal49GazUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Fractal77Gaz/Fractal77GazRenderer.swift b/vr-dive/Demos/Fractal77Gaz/Fractal77GazRenderer.swift new file mode 100644 index 0000000..b4640f4 --- /dev/null +++ b/vr-dive/Demos/Fractal77Gaz/Fractal77GazRenderer.swift @@ -0,0 +1,178 @@ +import Metal +import simd + +// Fractal77GazRenderer.swift +// +// Cube-container adaptation of ShaderToy "Fractal 77_gaz" (fdy3WG). +// The visible container is a 2 m × 2 m × 2 m cube. Rays start at the visible +// cube surface when viewed from outside, or at the viewer position when the +// camera is inside the cube. + +final class Fractal77GazRenderer: VisualPatternController { + let identifier: VisualPatternKind = .fractal77Gaz + 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 = Fractal77GazRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try Fractal77GazRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = Fractal77GazRenderer.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.62 + } + + 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 = Fractal77GazUniforms( + 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 Fractal77GazRenderer { + 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: "fractal77GazVertex") + desc.fragmentFunction = library.makeFunction(name: "fractal77GazFragment") + 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)! + } +} \ No newline at end of file diff --git a/vr-dive/Demos/Fractal77Gaz/Fractal77GazShaders.metal b/vr-dive/Demos/Fractal77Gaz/Fractal77GazShaders.metal new file mode 100644 index 0000000..fb83e72 --- /dev/null +++ b/vr-dive/Demos/Fractal77Gaz/Fractal77GazShaders.metal @@ -0,0 +1,190 @@ +// Fractal77GazShaders.metal +// "Fractal 77_gaz" — cube-container adaptation of ShaderToy fdy3WG. +// Source: https://www.shadertoy.com/view/fdy3WG +// This adaptation preserves the original axis-rotated recursive fold, distance +// accumulation, hue ramp, and strong post-power response while replacing the +// screen-space ray with a real per-eye world ray entering a visible 2 m cube. + +#include +using namespace metal; + +struct Fractal77GazUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct Fractal77GazVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct Fractal77Accum { + float3 color; + float energy; + float feature; +}; + +static constant float3 F77_BOX_HALF = float3(1.0f); +static constant float3 F77_AXIS = float3(0.26726124f, 0.53452248f, 0.80178373f); + +static float3 hue77(float h) { + return cos(h * 6.3f + float3(0.0f, 23.0f, 21.0f)) * 0.5f + 0.5f; +} + +static float3 f77Tonemap(float3 color) { + return color / (1.0f + color); +} + +static float3 rotateAroundAxis77(float3 p, float3 axis, float angle) { + float c = cos(angle); + float s = sin(angle); + return mix(axis * dot(p, axis), p, c) + s * cross(p, axis); +} + +static float2 f77BoxIntersect(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 f77FaceUV(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 reorderFold(float3 p) { + return p.x < p.y ? p.zxy : p.zyx; +} + +static Fractal77Accum traceFractal77(float3 ro, float3 rd, float time) { + Fractal77Accum accum; + accum.color = float3(0.0f); + accum.energy = 0.0f; + accum.feature = 0.0f; + + float g = 0.0f; + for (int stepIndex = 0; stepIndex < 99; ++stepIndex) { + float iteration = float(stepIndex + 1); + float3 p = ro + g * rd; + p.z -= 0.6f; + p = rotateAroundAxis77(p, F77_AXIS, time * 0.3f); + + float s = 4.0f; + float e = 0.0f; + for (int fractalIndex = 0; fractalIndex < 8; ++fractalIndex) { + p = abs(p); + p = reorderFold(p); + e = 1.8f / min(dot(p, p), 1.3f); + s *= e; + p = p * e - float3(12.0f, 3.0f, 3.0f); + } + + e = length(p.xz) / max(s, 1.0e-4f); + float3 tint = mix(float3(0.04f, 0.055f, 0.09f), hue77(log(max(s, 1.0e-4f))), 0.88f); + float ridge = exp(-34.0f * e); + float body = exp(-7.5f * e); + float depthFade = exp(-0.035f * iteration); + accum.color += tint * depthFade * (0.018f * body + 0.11f * ridge); + accum.energy += ridge * depthFade * 0.0022f; + accum.feature += ridge * depthFade * 0.09f; + + g += e; + if (g > 18.0f) { + break; + } + } + + accum.color = pow(f77Tonemap(clamp(accum.color * 1.45f, 0.0f, 6.0f)), float3(1.2f)); + accum.feature = clamp(accum.feature, 0.0f, 1.0f); + return accum; +} + +vertex Fractal77GazVertexOut fractal77GazVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant Fractal77GazUniforms &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; + + Fractal77GazVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +fragment float4 fractal77GazFragment( + Fractal77GazVertexOut in [[stage_in]], + constant Fractal77GazUniforms &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 cameraWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 eye = (cameraWorld - center) / scale; + float3 surfacePos = (in.worldPos - center) / scale; + float3 rd = normalize(surfacePos - eye); + + bool insideOuter = all(abs(eye) < F77_BOX_HALF - 1.0e-3f); + float2 tOuter = f77BoxIntersect(eye, rd, F77_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 localOrigin = eye + rd * (tStart + 0.001f); + + const float sceneScale = 2.8f; + float3 sceneRo = localOrigin * sceneScale; + float3 sceneRd = normalize(rd); + + Fractal77Accum accum = traceFractal77(sceneRo, sceneRd, uniforms.time); + + float axisFacing = pow(clamp(dot(sceneRd, F77_AXIS) * 0.5f + 0.5f, 0.0f, 1.0f), 3.0f); + float horizon = pow(clamp(1.0f - abs(sceneRd.y), 0.0f, 1.0f), 2.0f); + float3 background = mix( + float3(0.015f, 0.02f, 0.04f), + float3(0.16f, 0.08f, 0.23f), + horizon); + background += hue77(uniforms.time * 0.1f + axisFacing * 0.3f) * (0.008f + 0.02f * axisFacing); + + float2 faceUV = f77FaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.16f * dot(faceUV, faceUV); + float patternPresence = accum.feature; + + float3 pattern = accum.color * (1.08f + 0.45f * patternPresence); + float3 color = background * (0.06f + 0.04f * (1.0f - patternPresence)) + pattern; + color += accum.energy * float3(0.55f, 0.78f, 1.05f) * 0.001f; + color *= vignette; + color = f77Tonemap(max(color, 0.0f)); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Fractal77Gaz/Fractal77GazTypes.swift b/vr-dive/Demos/Fractal77Gaz/Fractal77GazTypes.swift new file mode 100644 index 0000000..515fcb9 --- /dev/null +++ b/vr-dive/Demos/Fractal77Gaz/Fractal77GazTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct Fractal77GazUniforms in Fractal77GazShaders.metal. +struct Fractal77GazUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} \ No newline at end of file diff --git a/vr-dive/Demos/FractalCity/FractalCityRenderer.swift b/vr-dive/Demos/FractalCity/FractalCityRenderer.swift new file mode 100644 index 0000000..8b59707 --- /dev/null +++ b/vr-dive/Demos/FractalCity/FractalCityRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// FractalCityRenderer.swift +// Cube-container adaptation of ShaderToy "Fractal city" (3ljyWz). + +final class FractalCityRenderer: VisualPatternController { + let identifier: VisualPatternKind = .fractalCity + 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, -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 = FractalCityRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FractalCityRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = FractalCityRenderer.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 = FractalCityUniforms( + 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 FractalCityRenderer { + 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: "fractalCityVertex") + desc.fragmentFunction = library.makeFunction(name: "fractalCityFragment") + 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/FractalCity/FractalCityShaders.metal b/vr-dive/Demos/FractalCity/FractalCityShaders.metal new file mode 100644 index 0000000..0770176 --- /dev/null +++ b/vr-dive/Demos/FractalCity/FractalCityShaders.metal @@ -0,0 +1,239 @@ +// FractalCityShaders.metal +// "Fractal city" — cube-container adaptation of ShaderToy 3ljyWz. +// Source: https://www.shadertoy.com/view/3ljyWz +// Source note: camera phase data and fold-based distance estimator come from the linked shader. + +#include +using namespace metal; + +struct FractalCityUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FractalCityVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct FCPhase { + float interval; + float3 pos0; + float3 pos1; + float3 dir0; + float3 dir1; + float up; +}; + +struct FCState { + float timeInPhase; + float phaseT; + FCPhase phase; +}; + +static constant float FC_ZOOM = 2.5f; +static constant float FC_HIT_EPS = 0.001f; +static constant float FC_MAX_H = 8.0f; +static constant int FC_MAX_STEPS = 100; +static constant float3 FC_BOX_HALF = float3(1.0f); +static constant FCPhase FC_PHASES[] = { + {8.0f, float3(0.9f, 1.2f, 0.4f), float3(0.6f, 1.0f, 0.8f), float3(1.0f, 0.0f, 1.0f), float3(1.0f, 1.0f, 0.0f), 0.0f}, + {9.0f, float3(0.0f, 0.3f, 0.6f), float3(0.0f, 0.0f, 0.6f), float3(0.0f, 1.0f, 1.0f), float3(1.0f, 1.0f, 1.0f), 2.0f}, + {8.0f, float3(0.0f, 0.0f, 0.4f), float3(0.0f, 0.0f, 1.2f), float3(1.0f, 0.0f, 1.0f), float3(1.0f, 1.0f, 1.0f), -3.0f}, + {8.0f, float3(0.0f, 0.4f, 0.7f), float3(0.4f, 0.0f, 0.7f), float3(1.0f, 1.0f, 0.0f), float3(0.0f, 1.0f, 1.0f), 0.0f}, + {7.0f, float3(0.6f, 0.6f, 0.3f), float3(0.6f, 0.8f, 0.0f), float3(1.0f, 0.0f, 1.0f), float3(1.0f, 1.0f, 0.0f), 3.0f}, + {9.0f, float3(-0.8f, 0.4f, 0.6f), float3(-0.8f, 0.6f, 0.0f), float3(0.0f, 0.0f, 1.0f), float3(1.0f, 0.0f, 1.0f), 1.0f}, +}; + +vertex FractalCityVertexOut fractalCityVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FractalCityUniforms &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; + + FractalCityVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 fcFold45(float2 p) { + return (p.y > p.x) ? p.yx : p; +} + +static float fcMap(float3 p) { + const float scale = 2.1f; + const float off0 = 0.8f; + const float off1 = 0.3f; + const float off2 = 0.83f; + const float3 off = float3(2.0f, 0.2f, 0.1f); + float s = 1.0f; + + for (int i = 0; i < 20; ++i) { + p.xy = abs(p.xy); + p.xy = fcFold45(p.xy); + p.y -= off0; + p.y = -abs(p.y); + p.y += off0; + p.x += off1; + p.xz = fcFold45(p.xz); + p.x -= off2; + p.xz = fcFold45(p.xz); + p.x += off1; + p -= off; + p *= scale; + p += off; + s *= scale; + } + + return length(p) / s; +} + +static float2 fcBoxIntersect(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 fcFaceUV(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 FCState fcPhaseState(float time) { + FCState state; + float cycle = 0.0f; + constexpr int count = int(sizeof(FC_PHASES) / sizeof(FCPhase)); + for (int i = 0; i < count; ++i) { + cycle += FC_PHASES[i].interval; + } + + float wrapped = fmod(time, cycle); + if (wrapped < 0.0f) { + wrapped += cycle; + } + + float accum = 0.0f; + state.phase = FC_PHASES[0]; + state.timeInPhase = wrapped; + state.phaseT = 0.0f; + + for (int i = 0; i < count; ++i) { + float nextAccum = accum + FC_PHASES[i].interval; + if (wrapped <= nextAccum || i == count - 1) { + state.phase = FC_PHASES[i]; + state.timeInPhase = wrapped - accum; + state.phaseT = clamp((wrapped - accum) / max(FC_PHASES[i].interval, 1.0e-4f), 0.0f, 1.0f); + return state; + } + accum = nextAccum; + } + + return state; +} + +fragment float4 fractalCityFragment( + FractalCityVertexOut in [[stage_in]], + constant FractalCityUniforms &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 rdLocal = normalize(surfacePos - eye); + + bool insideCube = all(abs(eye) < FC_BOX_HALF - 1.0e-3f); + float2 tCube = fcBoxIntersect(eye, rdLocal, FC_BOX_HALF); + if (!insideCube && tCube.x > tCube.y) { + discard_fragment(); + } + + float tStart = insideCube ? 0.0f : max(tCube.x, 0.0f); + float3 localOrigin = eye + rdLocal * (tStart + 0.001f); + + FCState state = fcPhaseState(uniforms.time); + FCPhase phase = state.phase; + float t = state.phaseT; + + float3 baseRo = mix(phase.pos0, phase.pos1, t) * FC_ZOOM; + float3 w = normalize(mix(phase.dir0, phase.dir1, t)); + float3 up = float3(sin(phase.up), cos(phase.up), 0.0f); + float3 u = normalize(cross(w, up)); + float3 v = cross(u, w); + + float3 ro = baseRo + (localOrigin.x * u + localOrigin.y * v + localOrigin.z * w) * 1.2f; + float3 rd = normalize(rdLocal.x * u + rdLocal.y * v + rdLocal.z * w); + + float h = 0.0f; + float d = 0.0f; + int stepCount = 1; + float3 p = ro; + bool hit = false; + for (int i = 1; i < FC_MAX_STEPS; ++i) { + stepCount = i; + p = ro + rd * h; + float3 samplePoint = p / FC_ZOOM; + d = fcMap(samplePoint); + if (d < FC_HIT_EPS) { + hit = true; + p = samplePoint; + break; + } + if (h > FC_MAX_H) { + p = samplePoint; + break; + } + h += d; + } + + float3 col = 30.0f * (cos(p * 1.2f) * 0.5f + 0.5f) / float(max(stepCount, 1)); + if (!hit) { + float horizon = pow(max(1.0f - abs(rdLocal.y), 0.0f), 4.0f); + float3 bg = mix(float3(0.008f, 0.012f, 0.022f), float3(0.045f, 0.06f, 0.09f), clamp(rdLocal.y * 0.5f + 0.5f, 0.0f, 1.0f)); + col = mix(bg, col, 0.35f); + col += horizon * float3(0.03f, 0.025f, 0.05f); + } + + float fog = smoothstep(2.0f, FC_MAX_H, h); + col = mix(col, float3(0.012f, 0.016f, 0.03f), fog * 0.45f); + col = clamp(col, 0.0f, 1.0f); + + float2 faceUV = fcFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + col = pow(col, float3(0.85f)); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/FractalCity/FractalCityTypes.swift b/vr-dive/Demos/FractalCity/FractalCityTypes.swift new file mode 100644 index 0000000..ba5b93e --- /dev/null +++ b/vr-dive/Demos/FractalCity/FractalCityTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct FractalCityUniforms in +/// FractalCityShaders.metal. +struct FractalCityUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/FractalFlythrough/FractalFlythroughRenderer.swift b/vr-dive/Demos/FractalFlythrough/FractalFlythroughRenderer.swift new file mode 100644 index 0000000..3dcd39a --- /dev/null +++ b/vr-dive/Demos/FractalFlythrough/FractalFlythroughRenderer.swift @@ -0,0 +1,171 @@ +import Metal +import simd + +// FractalFlythroughRenderer.swift +// +// Source reference requested by user: +// https://www.shadertoy.com/view/4s3SRN +// This is an original Metal adaptation for vr-dive that rebuilds the source +// shader as a view-independent raymarched volume rendered inside a 2 meter cube. + +final class FractalFlythroughRenderer: VisualPatternController { + let identifier: VisualPatternKind = .fractalFlythrough + 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 + + // Uniform 16m cube. Front face at z = objectCenter.z + cubeScale = -9.0 + 8.0 = -1.0. + private let cubeScale: Float = 8.0 + private let cubeScaleZ: Float = 8.0 + private let travelSpeed: Float = 0.4 + private let objectCenter = SIMD3(0.0, 0.0, -9.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 = FractalFlythroughRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try FractalFlythroughRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = FractalFlythroughRenderer.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 = FractalFlythroughUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + cubeScale: cubeScale, + travelSpeed: travelSpeed, + objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, cubeScaleZ)) + + 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 FractalFlythroughRenderer { + 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: "fractalFlythroughVertex") + desc.fragmentFunction = library.makeFunction(name: "fractalFlythroughFragment") + 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/FractalFlythrough/FractalFlythroughShaders.metal b/vr-dive/Demos/FractalFlythrough/FractalFlythroughShaders.metal new file mode 100644 index 0000000..0c5d7d7 --- /dev/null +++ b/vr-dive/Demos/FractalFlythrough/FractalFlythroughShaders.metal @@ -0,0 +1,317 @@ +// FractalFlythroughShaders.metal +// +// Source reference requested by user: +// https://www.shadertoy.com/view/4s3SRN +// This is an original Metal adaptation for vr-dive. It preserves the source +// shader's Catmull-Rom flythrough path and layered lattice ideas, but renders +// them as a view-independent 3D volume inside a 2 meter cube container. + +#include +using namespace metal; + +#define FFT_FAR 40.0f +#define FFT_MAX_STEPS 96 +#define FFT_HIT_EPS 0.001f +// Uniform scene scale. 4.0 → 2*4=8 scene units per side → 8/4=2 repetitions (3x larger than 12.0). +#define FFT_SCENE_SCALE 4.0f + +struct FractalFlythroughUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct FractalFlythroughVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct FractalSample { + float distance; + float material; +}; + +vertex FractalFlythroughVertexOut fractalFlythroughVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant FractalFlythroughUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + MeshVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + // Non-uniform scale: XY from cubeScale, Z from objectCenter.w + float3 scale3 = float3(uniforms.cubeScale, uniforms.cubeScale, uniforms.objectCenter.w); + float3 worldPos = vtx.position * scale3 + uniforms.objectCenter.xyz; + + FractalFlythroughVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static constant float3 FFT_CP[16] = { + float3(0.0f, 0.0f, 0.0f), + float3(0.0f, 0.0f, 3.84f), + float3(1.92f, 0.0f, 3.84f), + float3(1.92f, 0.0f, 1.92f), + float3(1.92f, 1.92f, 1.92f), + float3(-1.92f, 1.92f, 1.92f), + float3(-1.92f, 0.0f, 1.92f), + float3(-1.92f, 0.0f, 0.0f), + float3(0.0f, 0.0f, 0.0f), + float3(0.0f, 0.0f, -3.84f), + float3(0.0f, 3.84f, -3.84f), + float3(-1.92f, 3.84f, -3.84f), + float3(-1.92f, 0.0f, -3.84f), + float3(-1.92f, 0.0f, 0.0f), + float3(-1.92f, -1.92f, 0.0f), + float3(0.0f, -1.92f, 0.0f), +}; + +static float fft_hash(float n) { + return fract(cos(n) * 45758.5453f); +} + +static float fft_smin(float a, float b, float s) { + float h = clamp(0.5f + 0.5f * (b - a) / s, 0.0f, 1.0f); + return mix(b, a, h) - s * h * (1.0f - h); +} + +static float3 fft_catmull(float3 p0, float3 p1, float3 p2, float3 p3, float t) { + return (((-p0 + p1 * 3.0f - p2 * 3.0f + p3) * t * t * t + + (p0 * 2.0f - p1 * 5.0f + p2 * 4.0f - p3) * t * t + + (-p0 + p2) * t + + p1 * 2.0f) * 0.5f); +} + +static float3 fft_camPath(float t) { + const int count = 16; + float wrapped = fract(t / float(count)) * float(count); + int seg = int(floor(wrapped)); + float localT = wrapped - float(seg); + + int i0 = (seg + count - 1) % count; + int i1 = seg % count; + int i2 = (seg + 1) % count; + int i3 = (seg + 2) % count; + return fft_catmull(FFT_CP[i0], FFT_CP[i1], FFT_CP[i2], FFT_CP[i3], localT); +} + +static FractalSample fft_map(float3 q) { + float3 p = abs(fract(q / 4.0f) * 4.0f - 2.0f); + float tube = min(max(p.x, p.y), min(max(p.y, p.z), max(p.x, p.z))) - 4.0f / 3.0f - 0.015f; + + p = abs(fract(q / 2.0f) * 2.0f - 1.0f); + tube = max(tube, fft_smin(max(p.x, p.y), fft_smin(max(p.y, p.z), max(p.x, p.z), 0.05f), 0.05f) - 2.0f / 3.0f); + + float panel = fft_smin(max(p.x, p.y), fft_smin(max(p.y, p.z), max(p.x, p.z), 0.125f), 0.125f) - 0.5f; + float strip = step(p.x, 0.75f) * step(p.y, 0.75f) * step(p.z, 0.75f); + panel -= strip * 0.025f; + + p = abs(fract(q * 2.0f) * 0.5f - 0.25f); + float pan2 = min(p.x, min(p.y, p.z)) - 0.05f; + panel = max(abs(panel), abs(pan2)) - 0.0425f; + + p = abs(fract(q * 1.5f) / 1.5f - 1.0f / 3.0f); + tube = max(tube, min(max(p.x, p.y), min(max(p.y, p.z), max(p.x, p.z))) - 2.0f / 9.0f + 0.025f); + + p = abs(fract(q * 3.0f) / 3.0f - 1.0f / 6.0f); + tube = max(tube, min(max(p.x, p.y), min(max(p.y, p.z), max(p.x, p.z))) - 1.0f / 9.0f - 0.035f); + + FractalSample sample; + sample.distance = min(panel, tube); + sample.material = 1.0f + step(tube, panel) + step(panel, tube) * strip * 2.0f; + return sample; +} + +static float fft_trace(float3 ro, float3 rd, float tMax, thread FractalSample &hitSample) { + float t = 0.01f; + hitSample = fft_map(ro); + for (int i = 0; i < FFT_MAX_STEPS; ++i) { + float3 pos = ro + rd * t; + hitSample = fft_map(pos); + float h = hitSample.distance; + if (abs(h) < FFT_HIT_EPS * (t * 0.25f + 1.0f) || t > tMax) { + break; + } + t += h * 0.8f; + } + return t; +} + +static float3 fft_normal(float3 p) { + float2 e = float2(0.005f, 0.0f); + return normalize(float3( + fft_map(p + e.xyy).distance - fft_map(p - e.xyy).distance, + fft_map(p + e.yxy).distance - fft_map(p - e.yxy).distance, + fft_map(p + e.yyx).distance - fft_map(p - e.yyx).distance)); +} + +static float fft_ao(float3 pos, float3 nor) { + float scale = 2.0f; + float occ = 0.0f; + for (int i = 0; i < 5; ++i) { + float hr = 0.01f + float(i) * 0.5f / 4.0f; + float dd = fft_map(pos + nor * hr).distance; + occ += (hr - dd) * scale; + scale *= 0.7f; + } + return clamp(1.0f - occ, 0.0f, 1.0f); +} + +static bool fft_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 fft_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 fft_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)); +} + +static float3 fft_woodColor(float3 p) { + float rings = 0.5f + 0.5f * sin(p.x * 5.0f + sin(p.y * 2.0f) * 1.2f + p.z * 0.7f); + return mix(float3(0.25f, 0.16f, 0.08f), float3(0.47f, 0.30f, 0.16f), rings); +} + +static float3 fft_metalColor(float3 p) { + float brushed = 0.5f + 0.5f * sin(p.z * 8.0f + p.x * 2.0f); + return mix(float3(0.34f, 0.36f, 0.39f), float3(0.55f, 0.58f, 0.62f), brushed); +} + +static float3 fft_goldColor(float3 p) { + float glint = 0.5f + 0.5f * sin(p.x * 10.0f + p.y * 7.0f + p.z * 3.0f); + return mix(float3(0.68f, 0.44f, 0.12f), float3(0.98f, 0.84f, 0.32f), glint); +} + +fragment float4 fractalFlythroughFragment( + FractalFlythroughVertexOut in [[stage_in]], + constant FractalFlythroughUniforms &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; + // Non-uniform box scale: XY from cubeScale, Z from objectCenter.w + float3 scale3 = float3(uniforms.cubeScale, uniforms.cubeScale, uniforms.objectCenter.w); + float3 roLocal = (camWorld - center) / scale3; + float3 rdWorld = normalize(in.worldPos - camWorld); + float3 rdLocal = rdWorld / scale3; + + float tEntry, tExit; + if (!fft_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + // Virtual camera — exact port of mainImage() from ShaderToy 4s3SRN: + // speed = iTime*0.35 + 8; ro = camPath(speed); lk = camPath(speed+.5); + // fwd = normalize(lk-ro); rgt = normalize(vec3(fwd.z,0,-fwd.x)); up = cross(fwd,rgt); + float time = uniforms.time * uniforms.travelSpeed; + float speed = time * 0.35f + 8.0f; + float3 ro = fft_camPath(speed); // camera position + float3 lk = fft_camPath(speed + 0.5f); // look-at + float3 lp = lk + float3(0.0f, 0.25f, 0.0f); // light + + float3 fwd = normalize(lk - ro); + float3 rgt = normalize(float3(fwd.z, 0.0f, -fwd.x)); + float3 up = cross(fwd, rgt); + // Camera basis: columns are right, up, forward + float3x3 basis = float3x3(rgt, up, fwd); + + // Entry point in box-local space + float3 entryLocal = roLocal + rdLocal * max(tEntry, 0.0f); + + // Map box entry to scene space. + // Convention: front face center (local z=+1) anchors to the virtual camera (ro). + // Rays entering the front face travel in direction fwd — into the scene. + float3 roScene = ro + basis * ((entryLocal - float3(0.0f, 0.0f, 1.0f)) * FFT_SCENE_SCALE); + // Flip z so that local -z (into the box) maps to scene +fwd. + float3 rdScene = normalize(basis * float3(rdWorld.x, rdWorld.y, -rdWorld.z)); + + FractalSample hitSample; + float t = fft_trace(roScene, rdScene, FFT_FAR, hitSample); + + float3 col = float3(0.0f); + + if (t < FFT_FAR) { + float3 pos = roScene + rdScene * t; + float3 nor = fft_normal(pos); + + float3 li = lp - pos; + float lDist = max(length(li), 0.001f); + li /= lDist; + float atten = 1.0f / (1.0f + lDist * 0.125f + lDist * lDist * 0.05f); + + float occ = fft_ao(pos, nor); + // Diffuse and specular from reference: + // dif = pow(clamp(dot(nor,li),0,1), 4)*2; spe = pow(max(dot(reflect(-li,nor),-rd),0),8) + float dif = pow(clamp(dot(nor, li), 0.0f, 1.0f), 4.0f) * 2.0f; + float spe = pow(max(dot(reflect(-li, nor), -rdScene), 0.0f), 8.0f); + float spe2 = spe * spe; + + // Procedural material colors (replacing iChannel0 texture from reference) + float3 baseColor; + if (hitSample.material > 2.5f) { + baseColor = fft_goldColor(pos); + // Gold fire tint from reference shading block + float3 fire = pow(float3(1.5f, 1.0f, 1.0f) * baseColor, float3(8.0f, 2.0f, 1.5f)); + baseColor = baseColor + min(mix(float3(1.0f, 0.9f, 0.375f), float3(0.75f, 0.375f, 0.3f), fire), 2.0f) * 0.5f; + } else if (hitSample.material > 1.5f) { + baseColor = fft_metalColor(pos); + float grey = dot(baseColor, float3(0.299f, 0.587f, 0.114f)); + baseColor = float3(grey) * 0.7f + baseColor * 0.15f; + } else { + baseColor = fft_woodColor(pos); + } + + // Lighting from reference: + // col = col*(dif + .25 + vec3(.35,.45,.5)*spe) + vec3(.7,.9,1)*spe2 + col = baseColor * (dif + 0.25f + float3(0.35f, 0.45f, 0.5f) * spe) + + float3(0.7f, 0.9f, 1.0f) * spe2; + col *= occ * atten; + + // Fog from reference: col = mix(col, vec3(0), 1-exp(-t*t/FAR/FAR*20)) + float fogK = t * t * 20.0f / (FFT_FAR * FFT_FAR); + col = mix(max(col, 0.0f), float3(0.0f), 1.0f - exp(-fogK)); + } + + // Gamma from reference: sqrt(max(col, 0)) + return float4(sqrt(max(col, 0.0f)), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/FractalFlythrough/FractalFlythroughTypes.swift b/vr-dive/Demos/FractalFlythrough/FractalFlythroughTypes.swift new file mode 100644 index 0000000..baf3a71 --- /dev/null +++ b/vr-dive/Demos/FractalFlythrough/FractalFlythroughTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct FractalFlythroughUniforms in +/// FractalFlythroughShaders.metal. +struct FractalFlythroughUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/GlassBox/GlassBoxRenderer.swift b/vr-dive/Demos/GlassBox/GlassBoxRenderer.swift new file mode 100644 index 0000000..514b764 --- /dev/null +++ b/vr-dive/Demos/GlassBox/GlassBoxRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// GlassBoxRenderer.swift +// +// Renders a glass box with bilinear-patch internal structure using ray marching. +// Architecture follows RhombicDodecahedronRenderer: a bounding sphere mesh acts +// as the container; the fragment shader does all ray-marching work. +// +// Local BOXDIMS = (0.95, 0.95, 1.25). Circumscribed sphere radius ≈ 1.84. +// World-space box size = BOXDIMS * boxScale. The sphere radius matches accordingly. + +final class GlassBoxRenderer: VisualPatternController { + let identifier: VisualPatternKind = .glassBox + 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 + + // World-space scale and placement. + private let boxScale: Float = 0.84 + private let objectCenter = SIMD3(0.0, -0.05, -1.1) + private var animationTime: Float = 0 + private var lastSimulationTime: Float? + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + // Box mesh in local BOXDIMS space (slightly enlarged so the rasterised mesh + // fully covers all visible pixels before the fragment shader takes over). + let geo = GlassBoxRenderer.makeBox( + device: device, localHalfExtents: SIMD3(0.95, 0.95, 1.25) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try GlassBoxRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = GlassBoxRenderer.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) + + // Vertex buffers + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + + var uniforms = GlassBoxUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + boxScale: boxScale, + _pad: 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) + } + } + + // Fragment buffers + 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) + } + } + vpMatrices.withUnsafeBytes { + if let base = $0.baseAddress, $0.count > 0 { + encoder.setFragmentBytes(base, length: $0.count, index: 2) + } + } + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: indexCount, + indexType: .uint16, + indexBuffer: indexBuffer, + indexBufferOffset: 0) + } +} + +// MARK: - Geometry & Pipeline factory +extension GlassBoxRenderer { + + /// Build a box mesh with the given half-extents in local (BOXDIMS) space. + /// 6 faces × 4 verts = 24 vertices, 6 × 2 triangles = 36 indices. + /// Normals point outward so back-face culling shows the correct faces + /// when viewed from outside. + 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) + // Each face: 4 corner positions + outward normal, wound CCW from outside. + let faces: [(positions: [SIMD3], normal: SIMD3)] = [ + ([[-x, -y, z], [x, -y, z], [x, y, z], [-x, y, z]], [0, 0, 1]), // +Z + ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]), // -Z + ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]), // +X + ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]), // -X + ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]), // +Y + ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]), // -Y + ] + 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 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: "glassBoxVertex") + desc.fragmentFunction = library.makeFunction(name: "glassBoxFragment") + 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 // reverse-Z: near=1, far=0 + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} diff --git a/vr-dive/Demos/GlassBox/GlassBoxShaders.metal b/vr-dive/Demos/GlassBox/GlassBoxShaders.metal new file mode 100644 index 0000000..8508391 --- /dev/null +++ b/vr-dive/Demos/GlassBox/GlassBoxShaders.metal @@ -0,0 +1,452 @@ +// GlassBoxShaders.metal +// Adapted from ShaderToy "NslGRN" by Danil (2021+), CC BY-NC-SA 3.0. +// https://www.shadertoy.com/view/NslGRN +// +// Renders a glass box with bilinear-patch internal structure using ray marching. +// A bounding sphere mesh (built by GlassBoxRenderer.swift) is the container. +// The vertex shader transforms each sphere vertex to world space. +// The fragment shader reconstructs a world-space ray and runs the full glass-box +// ray-marching pipeline in box-local space. + +#include +using namespace metal; + +// ─── Uniforms ───────────────────────────────────────────────────────────────── +// Layout must match GlassBoxUniforms in GlassBoxTypes.swift. +struct GlassBoxUniforms { + float time; + uint viewCount; + float boxScale; + float _pad; + float4 objectCenter; // xyz = world-space box centre +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct GlassBoxVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ─── Vertex shader ──────────────────────────────────────────────────────────── +vertex GlassBoxVertexOut glassBoxVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant GlassBoxUniforms &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; + + GlassBoxVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ─── Constants ──────────────────────────────────────────────────────────────── +#define GB_PI 3.14159265f +#define GB_BOXDIMS float3(0.95f, 0.95f, 1.25f) +#define GB_IOR 1.33f + +// ─── Rotation matrices (column-major, same as GLSL) ────────────────────────── +static float3x3 gb_rotx(float a) { + float s = sin(a), c = cos(a); + return float3x3(float3(1,0,0), float3(0,c,s), float3(0,-s,c)); +} +static float3x3 gb_roty(float a) { + float s = sin(a), c = cos(a); + return float3x3(float3(c,0,s), float3(0,1,0), float3(-s,0,c)); +} +static float3x3 gb_rotz(float a) { + float s = sin(a), c = cos(a); + return float3x3(float3(c,s,0), float3(-s,c,0), float3(0,0,1)); +} + +// ─── Color palette (adapted from ShaderToy getColor) ───────────────────────── +static float3 gb_getColor(float3 p, float t_time) { + p = abs(p); + p *= 1.25f; + float dp = dot(p, p); + if (dp < 1e-8f) return float3(0.3f, 0.4f, 0.5f); + p = 0.5f * p / dp; + p += 0.072f * t_time; // continuous color animation + float t = 0.13f * length(p); + float3 col = float3(0.3f, 0.4f, 0.5f); + col += 0.12f * cos(6.28318f * t * 1.0f + float3(0.0f, 0.8f, 1.1f)); + col += 0.11f * cos(6.28318f * t * 3.1f + float3(0.3f, 0.4f, 0.1f)); + col += 0.10f * cos(6.28318f * t * 5.1f + float3(0.1f, 0.7f, 1.1f)); + col += 0.10f * cos(6.28318f * t * 17.1f + float3(0.2f, 0.6f, 0.7f)); + col += 0.10f * cos(6.28318f * t * 31.1f + float3(0.1f, 0.6f, 0.7f)); + col += 0.10f * cos(6.28318f * t * 65.1f + float3(0.0f, 0.5f, 0.8f)); + col += 0.10f * cos(6.28318f * t * 115.1f + float3(0.1f, 0.4f, 0.7f)); + col += 0.10f * cos(6.28318f * t * 265.1f + float3(1.1f, 1.4f, 2.7f)); + return clamp(col, 0.0f, 1.0f); +} + +// ─── calcColor ──────────────────────────────────────────────────────────────── +static void gb_calcColor( + float3 ro, float3 rd, float d, float len, bool si, float td, float t_time, + thread float4 &colx, thread float4 &colsi) +{ + const float edgeWidth = 0.03f; + float3 pos = ro + rd * d; + float a = 1.0f - smoothstep(len - edgeWidth, len + 1e-5f, length(pos)); + a = pow(clamp(a, 0.0f, 1.0f), 1.6f); + colx = float4(gb_getColor(pos, t_time), a); + if (si) { + pos = ro + rd * td; + float ta = 1.0f - smoothstep(len - edgeWidth, len + 1e-5f, length(pos)); + ta = pow(clamp(ta, 0.0f, 1.0f), 1.6f); + colsi = float4(gb_getColor(pos, t_time), ta); + } +} + +// ─── Bilinear-patch intersection ────────────────────────────────────────────── +// Direct translation of iBilinearPatch from the original GLSL. +// Polynomial coefficients are simplified using a=b=c=e=f=0. +static bool gb_bilinearPatch( + 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.xy, ph.x); + + t = -1.0f; tsi = -1.0f; si = false; fade = 1.0f; fadesi = 1.0f; + norm = normsi = float3(0, 1, 0); + + float tmp = 1.0f / (vb.y * vc.x); + float dd = va.z * tmp; + float gg = (vc.z * vb.y - vd.y * va.z) * tmp; + float hh = (vb.z * vc.x - va.z * vd.x) * tmp; + float jj = (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; + + // Quadratic: p*t^2 + q*t + r = 0 (simplified with a=b=c=e=f=0) + float p = dd * rd.x * rd.z; + float q = dd * (ro.x * rd.z + ro.z * rd.x) + gg * rd.x + hh * rd.z - rd.y; + float r = dd * ro.x * ro.z + gg * ro.x + hh * ro.z - ro.y + jj; + + // Inline normal computation (gradient in permuted xzy space) + // grad = pos.zxz*(dd,dd,0) + (gg,hh,-1) => (pos.z*dd+gg, pos.x*dd+hh, -1) +#define GB_NORM(pos_) (-normalize(float3((pos_).z * dd + gg, (pos_).x * dd + hh, -1.0f))) + + if (abs(p) < 1e-6f) { + if (abs(q) < 1e-12f) return false; + float tt = -r / q; + if (tt <= 0.0f) return false; + float3 pos = ro + tt * rd; + if (length(pos) > sz) return false; + t = tt; + norm = GB_NORM(pos); + 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; + if (step(sz, length(pos)) > 0.5f) return false; + + if ((tt2 > 0.0f) && !ru && !(step(sz, length(ro + tt2 * rd)) > 0.5f)) { + si = true; + fadesi = s; + tsi = tt2; + float3 tp = ro + tsi * rd; + normsi = GB_NORM(tp); + } + fade = s; + t = tt0; + norm = GB_NORM(pos); + return true; +#undef GB_NORM +} + +// ─── Ray vs axis-aligned box ────────────────────────────────────────────────── +// entering=true → returns near-t and sets nn to entry normal +// entering=false → returns far-t and sets nn to exit normal +static float gb_boxHit(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; + } else { + nn = sign(rd) * step(pout.xyz, pout.zxy) * step(pout.xyz, pout.yzx); + return tout; + } +} + +static float2 gb_faceCoords(float3 p, float3 faceNormal) { + return p.xy * faceNormal.z + p.yz * faceNormal.x + p.zx * faceNormal.y; +} + +static float3 gb_faceSpacePoint(float3 p, float3 facePoint, float3 faceNormal) { + return float3(gb_faceCoords(p, faceNormal), dot(facePoint - p, faceNormal)); +} + +static float3 gb_faceSpaceDir(float3 rd, float3 faceNormal) { + float3 dir = rd.yzx * faceNormal.x + rd.zxy * faceNormal.y + rd.xyz * faceNormal.z; + dir.z = -dir.z; + return dir; +} + +// ─── Background visible through refracted ray (simplified NO_SHADOW path) ──── +static float3 gb_background(float3 ro, float3 rd, float3 l_dir, thread float &alpha) { + float3 bgc = mix(float3(0.01f), float3(0.336f, 0.458f, 0.668f), + 1.0f - pow(abs(rd.z + 0.25f), 1.3f)); + float t = (-GB_BOXDIMS.z - ro.z) / rd.z; + alpha = 0.0f; + if (t < 0.0f) return bgc; + float2 uv = ro.xy + t * rd.xy; + float aofac = smoothstep(-0.95f, 0.75f, length(abs(uv) - min(abs(uv), float2(0.45f)))); + float lght = max(dot(normalize(ro + t * rd + float3(0,0,-5)), + normalize(l_dir - float3(0,0,1)) * gb_rotz(GB_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))); +} + +// ─── Internal bilinear-patch rendering (3 rotated layers) ──────────────────── +static void gb_applySceneRotation(thread float3 &p, float t_time) { + p = p * gb_roty(t_time * 0.23f); + p = p * gb_rotx(t_time * 0.17f); +} + +static void gb_applyLayerOrientation(int layerIndex, thread float3 &p) { + if (layerIndex == 1) { + p = p * gb_rotx(GB_PI * 0.5f); + } else if (layerIndex == 2) { + p = p * gb_roty(GB_PI * 0.5f); + p = p * gb_rotz(GB_PI * 0.5f); + } +} + +static float4 gb_insides(float3 ro, float3 rd, float3 l_dir, + float t_time, float maxDist, thread float &tout) +{ + tout = -1.0f; + + const float curvature = 0.5f; + const float bil_size = 1.0f; + float4 ps = float4(-bil_size, -bil_size, bil_size, bil_size) * curvature; + float4 ph = float4(-bil_size, bil_size, bil_size, -bil_size) * curvature; + + float4 colx[3] = { float4(0), float4(0), float4(0) }; + float3 dx[3] = { float3(-1), float3(-1), float3(-1) }; + float4 colxsi[3] = { float4(0), float4(0), float4(0) }; + int order[3] = { 0, 1, 2 }; + + for (int i = 0; i < 3; i++) { + float3 roLayer = ro; + float3 rdLayer = rd; + float3 lightLayer = l_dir; + + gb_applySceneRotation(roLayer, t_time); + gb_applySceneRotation(rdLayer, t_time); + gb_applySceneRotation(lightLayer, t_time); + gb_applyLayerOrientation(i, roLayer); + gb_applyLayerOrientation(i, rdLayer); + gb_applyLayerOrientation(i, lightLayer); + + float3 normnew; float tnew; + bool si; float tsi; float3 normsi; + float fade; float fadesi; + + if (gb_bilinearPatch(roLayer, rdLayer, ps, ph, bil_size, + tnew, normnew, si, tsi, normsi, fade, fadesi)) { + if (si && ((tsi <= 0.0f) || (tsi > maxDist))) { + si = false; + tsi = -1.0f; + } + + if ((tnew > 0.0f) && (tnew <= maxDist)) { + float4 tcol(0), tcolsi(0); + gb_calcColor(roLayer, rdLayer, tnew, bil_size, si, tsi, t_time, tcol, tcolsi); + if (tcol.a > 0.0f) { + dx[i] = float3(tnew, si ? 1.0f : 0.0f, tsi); + + float dif = clamp(dot(normnew, lightLayer), 0.0f, 1.0f); + float amb = clamp(0.5f + 0.5f * dot(normnew, lightLayer), 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 tv = float4( + tcol.rgb * shad * 1.4f + + 3.0f * (tcr * tcol.rgb) * clamp(1.0f - (amb + dif), 0.0f, 1.0f), + min(tcol.a, ta)); + tv.rgb = clamp(2.0f * tv.rgb * tv.rgb, 0.0f, 1.0f); + tv *= min(fade * 5.0f, 1.0f); + colx[i] = tv; + + if (si) { + dif = clamp(dot(normsi, lightLayer), 0.0f, 1.0f); + amb = clamp(0.5f + 0.5f * dot(normsi, lightLayer), 0.0f, 1.0f); + shad = float3(0.32f, 0.43f, 0.54f) * amb + + float3(1.0f, 0.9f, 0.7f) * dif; + float ta2 = clamp(length(tcolsi.rgb), 0.0f, 1.0f); + tcolsi = clamp(tcolsi * tcolsi * 2.0f, 0.0f, 1.0f); + float4 tv2 = float4( + tcolsi.rgb * shad + + 3.0f * (tcr * tcolsi.rgb) * clamp(1.0f - (amb+dif), 0.0f, 1.0f), + min(tcolsi.a, ta2)); + tv2.rgb = clamp(2.0f * tv2.rgb * tv2.rgb, 0.0f, 1.0f); + tv2.rgb *= min(fadesi * 5.0f, 1.0f); + colxsi[i] = tv2; + } + } + } + } + } + + // Sort dx[] descending by x (farthest first) — bubble sort 3 elements + // Inline the swap macro: {TYPE swap(a,b)} => TYPE _t=a;a=b;b=_t; + if (dx[0].x < dx[1].x) { + float3 _f = dx[0]; dx[0] = dx[1]; dx[1] = _f; + int _i = order[0]; order[0] = order[1]; order[1] = _i; + } + if (dx[1].x < dx[2].x) { + float3 _f = dx[1]; dx[1] = dx[2]; dx[2] = _f; + int _i = order[1]; order[1] = order[2]; order[2] = _i; + } + if (dx[0].x < dx[1].x) { + float3 _f = dx[0]; dx[0] = dx[1]; dx[1] = _f; + int _i = order[0]; order[0] = order[1]; order[1] = _i; + } + + tout = max(max(dx[0].x, dx[1].x), dx[2].x); + + float a = 1.0f; + if (dx[0].y < 0.5f) { a = colx[order[0]].a; } + + // Self-intersection compositing + bool rul[3] = { + (dx[0].y > 0.5f) && (dx[1].x <= 0.0f), + (dx[1].y > 0.5f) && (dx[0].x > dx[1].z), + (dx[2].y > 0.5f) && (dx[1].x > dx[2].z) + }; + for (int k = 0; k < 3; k++) { + if (rul[k]) { + float4 tsi2 = colxsi[order[k]]; + float4 tcx = colx[order[k]]; + float4 tv = mix(tsi2, tcx, tcx.a); + colx[order[k]] = mix(float4(0), tv, max(tcx.a, tsi2.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); + a = max(max(a, a1), a2); + return float4(col, a); +} + +// ─── Fragment shader ────────────────────────────────────────────────────────── +fragment float4 glassBoxFragment( + GlassBoxVertexOut in [[stage_in]], + constant GlassBoxUniforms &uniforms [[buffer(0)]], + constant float4x4 *v2wMats [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]]) +{ + uint vi = min(in.viewIndex, uniforms.viewCount - 1u); + + // Camera world position from viewToWorld transform column 3 + float4x4 v2w = v2wMats[vi]; + float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + + // Ray in world space → convert to box-local space + float3 center = uniforms.objectCenter.xyz; + float sc = uniforms.boxScale; + float3 eye = (camWorld - center) / sc; + float3 rd = normalize(in.worldPos - camWorld); // dir unchanged under uniform scale + + // Light direction (world-agnostic, same as original ShaderToy) + float3 l_dir = normalize(float3(0, 1, 0)) * gb_rotz(0.5f); + + bool insideBox = all(abs(eye) < (GB_BOXDIMS - 1e-3f)); + + float3 marchOrigin = eye; + float3 surfacePoint; + float3 surfaceNormal; + if (!insideBox) { + float3 entryNormal; + float tEnter = gb_boxHit(eye, rd, GB_BOXDIMS, entryNormal, true); + if (tEnter < 0.0f) discard_fragment(); + surfacePoint = eye + rd * tEnter; + surfaceNormal = entryNormal; + marchOrigin = surfacePoint + rd * 1e-3f; + } + + float3 exitNormal; + float tExit = gb_boxHit(marchOrigin, rd, GB_BOXDIMS, exitNormal, false); + if (tExit <= 0.0f) discard_fragment(); + + if (insideBox) { + surfacePoint = eye + rd * tExit; + surfaceNormal = -exitNormal; + } + + float R0 = (GB_IOR - 1.0f) / (GB_IOR + 1.0f); + R0 *= R0; + float cosTheta = clamp(dot(-rd, surfaceNormal), 0.0f, 1.0f); + float fresnel = R0 + (1.0f - R0) * pow(1.0f - cosTheta, 5.0f); + + float hitT; + float4 internalcol = gb_insides(marchOrigin, rd, l_dir, uniforms.time, tExit, hitT); + float3 motifCol = ((hitT > 0.0f) && (internalcol.a > 0.0f)) ? internalcol.rgb : float3(0.0f); + + float3 bouncePoint = marchOrigin + rd * tExit; + float3 bounceDir = reflect(rd, -exitNormal); + float3 bounceOrigin = bouncePoint + bounceDir * 1e-3f; + float3 bounceExitNormal; + float bounceMaxDist = gb_boxHit(bounceOrigin, bounceDir, GB_BOXDIMS, bounceExitNormal, false); + float reflectedHitT = -1.0f; + float4 reflectedCol = float4(0.0f); + if (bounceMaxDist > 0.0f) { + reflectedCol = gb_insides(bounceOrigin, bounceDir, l_dir, uniforms.time, bounceMaxDist, reflectedHitT); + } + + float reflectionAmount = 0.0f; + if ((reflectedHitT > 0.0f) && (reflectedCol.a > 0.0f)) { + reflectionAmount = clamp(max(fresnel * 0.55f, insideBox ? 0.08f : 0.12f), 0.0f, insideBox ? 0.18f : 0.22f); + } + + float3 reflectionCol = reflectedCol.rgb; + float3 col = mix(motifCol, reflectionCol, reflectionAmount); + + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/GlassBox/GlassBoxTypes.swift b/vr-dive/Demos/GlassBox/GlassBoxTypes.swift new file mode 100644 index 0000000..18656a7 --- /dev/null +++ b/vr-dive/Demos/GlassBox/GlassBoxTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct GlassBoxUniforms in GlassBoxShaders.metal. +struct GlassBoxUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float // uniform world-space scale applied to the local BOXDIMS + var _pad: Float // padding to keep float4 aligned + var objectCenter: SIMD4 // xyz = world position of box centre +} diff --git a/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesRenderer.swift b/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesRenderer.swift new file mode 100644 index 0000000..de8a3fb --- /dev/null +++ b/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// GlowingMountainLinesRenderer.swift +// +// Source reference: +// https://www.shadertoy.com/view/wcjyDm +// "CC0: Glowing mountain lines" +// Uses XorDev's dot noise: https://www.shadertoy.com/view/wfsyRX +// License: CC0 + +final class GlowingMountainLinesRenderer: VisualPatternController { + let identifier: VisualPatternKind = .glowingMountainLines + 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 = GlowingMountainLinesRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try GlowingMountainLinesRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = GlowingMountainLinesRenderer.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 = GlowingMountainLinesUniforms( + 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 GlowingMountainLinesRenderer { + 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: "glowingMountainLinesVertex") + desc.fragmentFunction = library.makeFunction(name: "glowingMountainLinesFragment") + 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/GlowingMountainLines/GlowingMountainLinesShaders.metal b/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesShaders.metal new file mode 100644 index 0000000..a176282 --- /dev/null +++ b/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesShaders.metal @@ -0,0 +1,211 @@ +// GlowingMountainLinesShaders.metal +// +// Source reference: +// https://www.shadertoy.com/view/wcjyDm +// "CC0: Glowing mountain lines" +// Uses XorDev's dot noise: https://www.shadertoy.com/view/wfsyRX +// License: CC0 +// +// Adapted for vr-dive: renders inside a view-independent 2 metre cube container. +// The original fixed ShaderToy camera is replaced by visionOS head-pose rays. +// A box intersection constrains the march; the DDA depth state is initialised +// at the box entry so the mountain layer stack is visible from all directions. +// +// Key adaptation notes: +// - The original uses a DDA that alternates sampling Z.x and Z.z depth arms. +// Both arms initialise at the box entry depth, with Z.z carrying the +// time-based phase offset (matching fract(-iTime)/I.z in the original). +// - Step size is capped at 0.5/max(|I|, 0.05) to prevent degenerate huge +// jumps when the ray is nearly parallel to an axis. Samples that land +// outside the box depth range are skipped, so the 120-step budget is +// never wasted computing outside the visible volume. +// - I[j] (ray direction component used for the perspective anti-aliasing +// term) naturally becomes large when the ray is shallow in that direction, +// which suppresses those arm contributions — matching the original intent. + +#include +using namespace metal; + +// Maps local box [-1,1] to scene units. Mountains occupy roughly z ∈ [0, 10]. +// With tFar ≤ 2 (local) and SCALE = 5: scene depth = 2 × 5 = 10 units. ✓ +#define GML_SCENE_SCALE 5.0f +#define GML_STEPS 120 + +struct GlowingMountainLinesUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct GlowingMountainLinesVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ── Vertex shader ───────────────────────────────────────────────────────────── +vertex GlowingMountainLinesVertexOut glowingMountainLinesVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant GlowingMountainLinesUniforms &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; + + GlowingMountainLinesVertexOut 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 gml_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 glowingMountainLinesFragment( + GlowingMountainLinesVertexOut in [[stage_in]], + constant GlowingMountainLinesUniforms &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 (!gml_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tNear, tFar)) { + discard_fragment(); + } + + float tStart = max(tNear, 0.0f); + float iTime = uniforms.time; + + // I = normalized ray direction (same role as I in the original ShaderToy) + float3 I = rdLocal; + + // Scene-space depth bounds + float zStart = tStart * GML_SCENE_SCALE; + float zEnd = tFar * GML_SCENE_SCALE; + + // Camera origin in scene space. Must be added to every sampled point so that + // the left and right eyes sample different scene positions — without this the + // two eyes see identical images and there is no stereo depth perception. + float3 roScene = roLocal * GML_SCENE_SCALE; + + // ── DDA state vector Z ──────────────────────────────────────────────────── + // Original: Z = fract(-T)/I.z where T = vec3(0,0,iTime) + // → Z.x = 0, Z.y = 0, Z.z = fract(-iTime)/I.z + // + // VR adaptation: both arms start at box entry depth (zStart / 0.2 = zStart*5), + // with Z.z carrying the time-based animation phase offset. + float3 T = float3(0.0f, 0.0f, iTime); + float absIz = max(abs(I.z), 0.1f); + + float3 Z; + Z.x = zStart * 5.0f; // z_x = 0.2*Z.x = zStart + Z.y = 0.0f; // unused (j never == 1) + Z.z = zStart * 5.0f + fract(-iTime) / absIz; // z_z = zStart + phase + + // Per-iteration DDA step: original Z += 0.5/abs(I) + // Capped to prevent degenerate huge jumps for near-axis rays. + float3 dZ = 0.5f / max(abs(I), float3(0.05f)); + + // ── Ray march — port of mainImage() from ShaderToy wcjyDm ──────────────── + float4 o = float4(0.0f); // accumulated volumetric colour (original: vec4 o) + float4 O4 = float4(0.0f); // per-sample colour (original: vec4 O) + + for (int i = 0; i < GML_STEPS; i++) { + // Pick whichever DDA arm is currently at the smaller depth (original: j = Z.x zEnd) continue; + + // 3D world position. roScene carries the per-eye camera offset so that + // left/right eyes sample different scene points at the same depth → stereo. + // Original: p = z*I + 0.2*T (camera at origin); VR: p = roScene + z*I + 0.2*T + float3 p = roScene + z * I + 0.2f * T; + + // Height-field base distance (original: d = p.y) + float d = p.y; + + // Per-sample colour from position (original: O = 1+sin(.5*p.x+p.z+vec4(2,7,0,2))) + // This is set as the for-loop init (before the noise octave loop). + float baseArg = 0.5f * p.x + p.z; + O4 = 1.0f + sin(baseArg + float4(2.0f, 7.0f, 0.0f, 2.0f)); + + // ── 3-octave fractal noise (port of inner for-loop) ────────────────── + // Original structure: + // for(O=...; a>.1; p.xy*=mat2(6,8,-8,6)/8.) + // d += a + a*dot(sin(p), cos(p.yzx*1.62)), a*=.5; + // + // GLSL mat2(6,8,-8,6) is column-major: col0=(6,8), col1=(-8,6). + // Row-vector left-multiplication: new.x = 6*p.x+8*p.y, new.y = -8*p.x+6*p.y (then /8). + float a = 0.6f; + while (a > 0.1f) { + // Dot noise by XorDev: d += a + a*dot(sin(p), cos(p.yzx*1.62)) + float3 pyzx = float3(p.y, p.z, p.x) * 1.62f; + d += a + a * dot(sin(p), cos(pyzx)); + a *= 0.5f; + // Rotate & scale p.xy (for-loop update step) + float px = p.x, py = p.y; + p.x = (6.0f * px + 8.0f * py) / 8.0f; + p.y = (-8.0f * px + 6.0f * py) / 8.0f; + } + // After loop: a ≈ 0.075 (unused); reassign to cosh-based depth falloff. + + // ── Accumulate volumetric contribution ──────────────────────────────── + // Original: a=cosh(8.-z); + // o += O.w / (abs(d)*5e2 + .3/I[j]/I[j] + 5./(8.z?1.:a) * O; + // + // Near-field (z<8): second divisor = 1, cosh term suppresses bright near-z + // Far-field (z>8): second divisor = cosh(z-8) → rapid falloff + a = cosh(8.0f - z); + bool isFar = (z > 8.0f); + float Ij_sq = max(I[j] * I[j], 0.001f); // perspective anti-alias term + float denom = abs(d) * 500.0f + + 0.3f / Ij_sq + + 5.0f / (isFar ? 1.0f : a); + float fadeFar = isFar ? a : 1.0f; + o += O4.w / denom / fadeFar * O4; + } + + // Tanh tone mapping (original: O = tanh(o)) + float4 col = tanh(o); + return float4(col.rgb, 1.0f); +} diff --git a/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesTypes.swift b/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesTypes.swift new file mode 100644 index 0000000..aa45e93 --- /dev/null +++ b/vr-dive/Demos/GlowingMountainLines/GlowingMountainLinesTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct GlowingMountainLinesUniforms in +/// GlowingMountainLinesShaders.metal. +struct GlowingMountainLinesUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/GoldenApollian/GoldenApollianRenderer.swift b/vr-dive/Demos/GoldenApollian/GoldenApollianRenderer.swift new file mode 100644 index 0000000..4452719 --- /dev/null +++ b/vr-dive/Demos/GoldenApollian/GoldenApollianRenderer.swift @@ -0,0 +1,172 @@ +import Metal +import simd + +// GoldenApollianRenderer.swift +// Cube-container adaptation of ShaderToy "Golden apollian" (WlcfRS). + +final class GoldenApollianRenderer: VisualPatternController { + let identifier: VisualPatternKind = .goldenApollian + 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, -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 = GoldenApollianRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try GoldenApollianRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = GoldenApollianRenderer.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 = GoldenApollianUniforms( + 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 GoldenApollianRenderer { + 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: "goldenApollianVertex") + desc.fragmentFunction = library.makeFunction(name: "goldenApollianFragment") + 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/GoldenApollian/GoldenApollianShaders.metal b/vr-dive/Demos/GoldenApollian/GoldenApollianShaders.metal new file mode 100644 index 0000000..afe25d3 --- /dev/null +++ b/vr-dive/Demos/GoldenApollian/GoldenApollianShaders.metal @@ -0,0 +1,488 @@ +// GoldenApollianShaders.metal +// "Golden apollian" — cube-container adaptation of ShaderToy WlcfRS. +// Source: https://www.shadertoy.com/view/WlcfRS +// License: CC0. +// +// Adaptation notes: +// - The original shader renders a forward-moving screen-space camera through a +// stack of procedural planes. This version reconstructs a real per-eye ray, +// starts it at the visible 2 m cube surface, and maps that ray into the +// original path-following camera frame so the content remains visible from all +// directions and also when the viewer is inside the cube. +// - The procedural plane stack is evaluated in scene space beyond the container, +// so the effect itself is not clipped by cube bounds. + +#include +using namespace metal; + +struct GoldenApollianUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct GoldenApollianVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct GAEffect { + float lw; + float tw; + float sk; + float cs; +}; + +static constant float GA_PI = 3.141592654f; +static constant float GA_TAU = 6.283185307f; +static constant float GA_PLANE_PERIOD = 5.0f; +static constant float3 GA_STD_GAMMA = float3(2.2f, 2.2f, 2.2f); +static constant float3 GA_PLANE_COL = float3(1.0f, 1.2f, 1.5f); +static constant float3 GA_BASE_RING_COL = float3(1.0f, 0.772459f, 0.435275f); +static constant float3 GA_SUN_COL = float3(1.0f, 0.8f, 0.88f); +static constant float GA_SCENE_SCALE = 0.35f; +static constant float3 GA_BOX_HALF = float3(1.0f); +static constant int GA_FURTHEST = 9; +static constant int GA_FADE_FROM = 5; + +vertex GoldenApollianVertexOut goldenApollianVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant GoldenApollianUniforms &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; + + GoldenApollianVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static GAEffect gaEffectForIndex(int idx) { + switch (idx) { + case 0: return GAEffect{0.125f, 0.0f, 0.0f, 0.0f}; + case 1: return GAEffect{0.125f, 0.0f, 0.0f, 1.0f}; + case 2: return GAEffect{0.125f, 0.0f, 1.0f, 1.0f}; + case 3: return GAEffect{0.125f, 1.0f, 1.0f, 1.0f}; + case 4: return GAEffect{0.125f, 1.0f, 1.0f, 0.0f}; + default: return GAEffect{0.125f, 1.0f, 0.0f, 0.0f}; + } +} + +static float2 gaRotate(float2 p, float angle) { + float c = cos(angle); + float s = sin(angle); + return float2(c * p.x + s * p.y, -s * p.x + c * p.y); +} + +static float gaPSin(float x) { + return 0.5f + 0.5f * sin(x); +} + +static float gaHash(float x) { + x += 100.0f; + return fract(sin(x * 12.9898f) * 13758.5453f); +} + +static float2 gaToPolar(float2 p) { + return float2(length(p), atan2(p.y, p.x)); +} + +static float2 gaToRect(float2 p) { + return float2(p.x * cos(p.y), p.x * sin(p.y)); +} + +static float gaTanhApprox(float x) { + float x2 = x * x; + return clamp(x * (27.0f + x2) / (27.0f + 9.0f * x2), -1.0f, 1.0f); +} + +static float gaPMin(float a, float b, float k) { + float h = clamp(0.5f + 0.5f * (b - a) / k, 0.0f, 1.0f); + return mix(b, a, h) - k * h * (1.0f - h); +} + +static float gaCircle(float2 p, float r) { + return length(p) - r; +} + +static float gaHex(float2 p, float r) { + const float3 k = float3(-0.86602540378f, 0.5f, 0.57735026919f); + p = p.yx; + p = abs(p); + p -= 2.0f * min(dot(k.xy, p), 0.0f) * k.xy; + p -= float2(clamp(p.x, -k.z * r, k.z * r), r); + return length(p) * sign(p.y); +} + +static float gaL2(float3 v) { + return dot(v, v); +} + +static float gaModMirror1(thread float &p, float size) { + float halfsize = size * 0.5f; + float c = floor((p + halfsize) / size); + p = fmod(p + halfsize, size); + if (p < 0.0f) { + p += size; + } + p -= halfsize; + p *= fmod(c, 2.0f) * 2.0f - 1.0f; + return c; +} + +static float gaSabs(float x, float k) { + float ax = abs(x); + float quad = (0.5f / k) * x * x + k * 0.5f; + return mix(quad, ax, step(0.0f, ax - k)); +} + +static float gaSmoothKaleidoscope(thread float2 &p, float sm, float rep) { + float2 polar = gaToPolar(p); + float angle = polar.y; + float rn = gaModMirror1(angle, GA_TAU / rep); + polar.y = angle; + float sa = GA_PI / rep - gaSabs(GA_PI / rep - abs(polar.y), sm); + polar.y = copysign(sa, polar.y); + p = gaToRect(polar); + return rn; +} + +static float4 gaAlphaBlend(float4 back, float4 front) { + float w = front.w + back.w * (1.0f - front.w); + if (w <= 0.0f) { + return float4(0.0f); + } + float3 xyz = (front.xyz * front.w + back.xyz * back.w * (1.0f - front.w)) / w; + return float4(xyz, w); +} + +static float3 gaAlphaBlend3(float3 back, float4 front) { + return mix(back, front.xyz, front.w); +} + +static float gaApollian(float4 p, float s, GAEffect effect) { + float scale = 1.0f; + for (int i = 0; i < 7; ++i) { + p = -1.0f + 2.0f * fract(0.5f * p + 0.5f); + float r2 = dot(p, p); + float k = s / max(r2, 1.0e-5f); + p *= k; + scale *= k; + } + + float lw = 0.00125f * effect.lw; + float d0 = abs(p.y) - lw * scale; + float d1 = abs(gaCircle(p.xz, 0.005f * scale)) - lw * scale; + float d = d0; + d = mix(d, min(d, d1), effect.tw); + return d / scale; +} + +static float3 gaOffset(float z) { + float a = z; + float2 p = -0.075f * ( + float2(cos(a), sin(a * sqrt(2.0f))) + + float2(cos(a * sqrt(0.75f)), sin(a * sqrt(0.5f)))); + return float3(p, z); +} + +static float3 gaDOffset(float z) { + float eps = 0.1f; + return 0.5f * (gaOffset(z + eps) - gaOffset(z - eps)) / eps; +} + +static float3 gaDDOffset(float z) { + float eps = 0.1f; + return 0.125f * (gaDOffset(z + eps) - gaDOffset(z - eps)) / eps; +} + +static float gaWeird(float2 p, float h, float time, GAEffect effect) { + float z = 4.0f; + float tm = 0.1f * time + h * 10.0f; + p = gaRotate(p, tm * 0.5f); + float r = 0.5f; + float4 off = float4( + r * gaPSin(tm * sqrt(3.0f)), + r * gaPSin(tm * sqrt(1.5f)), + r * gaPSin(tm * sqrt(2.0f)), + 0.0f); + float4 pp = float4(p.x, p.y, 0.0f, 0.0f) + off; + pp.w = 0.125f * (1.0f - gaTanhApprox(length(pp.xyz))); + pp.yz = gaRotate(pp.yz, tm); + pp.xz = gaRotate(pp.xz, tm * sqrt(0.5f)); + pp /= z; + float d = gaApollian(pp, 0.8f + h, effect); + return d * z; +} + +static float gaCircles(float2 p) { + float2 pp = gaToPolar(p); + const float ss = 2.0f; + pp.x = fract(pp.x / ss) * ss; + p = gaToRect(pp); + return gaCircle(p, 1.0f); +} + +static float2 gaDf2(float2 p, float h, float time, GAEffect effect) { + float2 wp = p; + float rep = 2.0f * round(mix(5.0f, 15.0f, h * h)); + float ss = 0.05f * 6.0f / rep; + + if (effect.sk > 0.0f) { + gaSmoothKaleidoscope(wp, ss, rep); + } + + float d0 = gaWeird(wp, h, time, effect); + float d1 = gaHex(p, 0.25f) - 0.1f; + float d2 = gaCircles(p); + const float lw = 0.0125f; + d2 = abs(d2) - lw; + float d = d0; + + if (effect.cs > 0.0f) { + d = gaPMin(d, d2, 0.1f); + } + + d = gaPMin(d, abs(d1) - lw, 0.1f); + d = max(d, -(d1 + lw)); + return float2(d, d1 + lw); +} + +static float2 gaDf3(float3 p, float3 off, float s, float2x2 rot, float h, float time, GAEffect effect) { + float2 p2 = p.xy - off.xy; + p2 = rot * p2; + return gaDf2(p2 / s, h, time, effect) * s; +} + +static float3 gaSkyColor(float3 rd) { + float ld = max(dot(rd, float3(0.0f, 0.0f, 1.0f)), 0.0f); + return GA_SUN_COL * gaTanhApprox(3.0f * pow(ld, 100.0f)); +} + +static float2x2 gaRotMatrix(float a) { + float c = cos(a); + float s = sin(a); + return float2x2(float2(c, -s), float2(s, c)); +} + +static float4 gaPlane( + float3 ro, + float3 rd, + float3 pp, + float pd, + float3 off, + float aa, + float planeNumber, + float time +) { + int pi = int(fmod(floor(planeNumber / GA_PLANE_PERIOD), 6.0f)); + if (pi < 0) { + pi += 6; + } + GAEffect effect = gaEffectForIndex(pi); + + float h = gaHash(planeNumber); + float s = 0.25f * mix(0.5f, 0.25f, h); + + const float3 nor = float3(0.0f, 0.0f, -1.0f); + const float3 loff = 2.0f * float3(0.125f, 0.0625f, -0.125f); + float3 lp1 = ro + loff; + float3 lp2 = ro + loff * float3(-2.0f, 1.0f, 1.0f); + + float2x2 rot = gaRotMatrix(GA_TAU * h); + float2 d2 = gaDf3(pp, off, s, rot, h, time, effect); + + float3 ld1 = normalize(lp1 - pp); + float3 ld2 = normalize(lp2 - pp); + float dif1 = pow(max(dot(nor, ld1), 0.0f), 5.0f); + float dif2 = pow(max(dot(nor, ld2), 0.0f), 5.0f); + float3 ref = reflect(rd, nor); + float spe1 = pow(max(dot(ref, ld1), 0.0f), 30.0f); + float spe2 = pow(max(dot(ref, ld2), 0.0f), 30.0f); + + const float boff = 0.00625f; + float dbt = boff / max(rd.z, 1.0e-4f); + + float3 bpp = ro + (pd + dbt) * rd; + float3 srd1 = normalize(lp1 - bpp); + float3 srd2 = normalize(lp2 - bpp); + float bl21 = gaL2(lp1 - bpp); + float bl22 = gaL2(lp2 - bpp); + + float st1 = -boff / min(srd1.z, -1.0e-4f); + float st2 = -boff / min(srd2.z, -1.0e-4f); + + float3 spp1 = bpp + st1 * srd1; + float3 spp2 = bpp + st2 * srd2; + + float2 bd = gaDf3(bpp, off, s, rot, h, time, effect); + float2 sd1 = gaDf3(spp1, off, s, rot, h, time, effect); + float2 sd2 = gaDf3(spp2, off, s, rot, h, time, effect); + + float3 col = float3(0.0f); + const float ss = 200.0f; + col += 0.1125f * GA_PLANE_COL * dif1 * (1.0f - exp(-ss * max(sd1.x, 0.0f))) / max(bl21, 1.0e-4f); + col += 0.05625f * GA_PLANE_COL * dif2 * (1.0f - exp(-ss * max(sd2.x, 0.0f))) / max(bl22, 1.0e-4f); + + float3 ringCol = GA_BASE_RING_COL; + ringCol *= clamp(0.1f + 2.5f * (0.1f + 0.25f * ((dif1 * dif1 / max(bl21, 1.0e-4f)) + (dif2 * dif2 / max(bl22, 1.0e-4f)))), 0.0f, 1.0f); + ringCol += sqrt(GA_BASE_RING_COL) * spe1 * 2.0f; + ringCol += sqrt(GA_BASE_RING_COL) * spe2 * 2.0f; + col = mix(col, ringCol, smoothstep(-aa, aa, -d2.x)); + + float ha = smoothstep(-aa, aa, bd.y); + return float4(col, mix(0.0f, 1.0f, ha)); +} + +static float3 gaPostProcess(float3 col, float2 q) { + col = clamp(col, 0.0f, 1.0f); + col = pow(col, 1.0f / GA_STD_GAMMA); + col = col * 0.6f + 0.4f * col * col * (3.0f - 2.0f * col); + col = mix(col, float3(dot(col, float3(0.33f))), -0.4f); + col *= 0.5f + 0.5f * pow(max(19.0f * q.x * q.y * (1.0f - q.x) * (1.0f - q.y), 0.0f), 0.7f); + return col; +} + +static float2 gaBoxIntersect(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 gaFaceUV(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 gaColorAlongPath(float3 ww, float3 uu, float3 vv, float3 ro, float2 p, float time) { + float2 np = p + float2(0.0015f, 0.0015f); + float rdd = 2.0f - 0.5f * gaTanhApprox(length(p)); + + float3 rd = normalize(p.x * uu + p.y * vv + rdd * ww); + float3 nrd = normalize(np.x * uu + np.y * vv + rdd * ww); + if (abs(rd.z) < 1.0e-4f || abs(nrd.z) < 1.0e-4f) { + return gaSkyColor(rd); + } + + const float planeDist = 0.25f; + const float fadeDist = planeDist * float(GA_FURTHEST - GA_FADE_FROM); + float nz = floor(ro.z / planeDist); + float3 skyCol = gaSkyColor(rd); + + float4 accum = float4(0.0f); + const float cutOff = 0.95f; + for (int i = 1; i <= GA_FURTHEST; ++i) { + float planeIndex = nz + float(i); + float pz = planeDist * planeIndex; + float pd = (pz - ro.z) / rd.z; + + if (pd > 0.0f && accum.w < cutOff) { + float3 pp = ro + rd * pd; + float3 npp = ro + nrd * pd; + float aa = 3.0f * length(pp - npp); + float3 off = gaOffset(pp.z); + + float4 pcol = gaPlane(ro, rd, pp, pd, off, aa, planeIndex, time); + float dz = pp.z - ro.z; + float fadeIn = exp(-2.5f * max((dz - planeDist * float(GA_FADE_FROM)) / max(fadeDist, 1.0e-4f), 0.0f)); + float fadeOut = smoothstep(0.0f, planeDist * 0.1f, dz); + pcol.xyz = mix(skyCol, pcol.xyz, fadeIn); + pcol.w *= fadeOut; + pcol = clamp(pcol, 0.0f, 1.0f); + accum = gaAlphaBlend(pcol, accum); + } else { + break; + } + } + + return gaAlphaBlend3(skyCol, accum); +} + +fragment float4 goldenApollianFragment( + GoldenApollianVertexOut in [[stage_in]], + constant GoldenApollianUniforms &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 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); + + bool insideOuter = all(abs(eye) < GA_BOX_HALF - 1.0e-3f); + float2 tOuter = gaBoxIntersect(eye, viewDir, GA_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 localOrigin = eye + viewDir * (tStart + 0.001f); + + float time = uniforms.time; + float tm = time * 0.125f; + float3 pathOrigin = gaOffset(tm); + float3 tangent = normalize(gaDOffset(tm)); + float3 curvature = gaDDOffset(tm); + float3 binormal = normalize(cross(normalize(float3(0.0f, 1.0f, 0.0f) + curvature), tangent)); + if (!all(isfinite(binormal)) || length(binormal) < 1.0e-4f) { + binormal = normalize(cross(float3(1.0f, 0.0f, 0.0f), tangent)); + } + float3 normal = cross(tangent, binormal); + + float3 sceneOrigin = localOrigin * GA_SCENE_SCALE; + sceneOrigin.xy += pathOrigin.xy; + sceneOrigin.z += tm; + + float3 ww = tangent; + float3 uu = binormal; + float3 vv = normal; + float2 p = float2(dot(viewDir, uu), dot(viewDir, vv)) / max(dot(viewDir, ww), 0.22f); + + float3 col = gaColorAlongPath(ww, uu, vv, sceneOrigin, p, time); + + float trail = pow(clamp(1.0f - abs(dot(viewDir, ww)), 0.0f, 1.0f), 2.0f); + float haze = 0.04f / (0.12f + abs(viewDir.y)); + float3 background = float3(0.006f, 0.008f, 0.014f); + background += GA_BASE_RING_COL * (0.035f + 0.10f * trail); + background += float3(0.8f, 0.68f, 0.52f) * haze * 0.06f; + + float2 faceUV = gaFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + + col += background; + col = gaPostProcess(col, gaFaceUV(surfacePos)); + col = sqrt(max(col, 0.0f)); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/GoldenApollian/GoldenApollianTypes.swift b/vr-dive/Demos/GoldenApollian/GoldenApollianTypes.swift new file mode 100644 index 0000000..d410b6a --- /dev/null +++ b/vr-dive/Demos/GoldenApollian/GoldenApollianTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct GoldenApollianUniforms in +/// GoldenApollianShaders.metal. +struct GoldenApollianUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollRenderer.swift b/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollRenderer.swift new file mode 100644 index 0000000..55088ad --- /dev/null +++ b/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// GreatDodecaheadrollRenderer.swift +// +// Cube-container adaptation of ShaderToy "Great Dodecaheadroll" (tf23DD). +// 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 GreatDodecaheadrollRenderer: VisualPatternController { + let identifier: VisualPatternKind = .greatDodecaheadroll + 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 = GreatDodecaheadrollRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try GreatDodecaheadrollRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = GreatDodecaheadrollRenderer.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 = GreatDodecaheadrollUniforms( + 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 GreatDodecaheadrollRenderer { + 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: "greatDodecaheadrollVertex") + desc.fragmentFunction = library.makeFunction(name: "greatDodecaheadrollFragment") + 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/GreatDodecaheadroll/GreatDodecaheadrollShaders.metal b/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollShaders.metal new file mode 100644 index 0000000..70c2cfc --- /dev/null +++ b/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollShaders.metal @@ -0,0 +1,237 @@ +// GreatDodecaheadrollShaders.metal +// "Great Dodecaheadroll" — cube-container adaptation of ShaderToy "tf23DD" +// Source: https://www.shadertoy.com/view/tf23DD +// +// Source notes: +// - The original shader ray-marches a transparent great dodecahedron from a +// fixed screen-space camera and uses repeated side flips plus refraction to +// create the rolling glassy look. +// - This Metal version keeps the pyramid-based SDF, surface normal, Fresnel and +// refraction behavior, but reconstructs the ray from the real per-eye camera +// entering a visible 2 m cube container. +// - When the viewer is outside the cube, marching begins at the cube surface; +// when the viewer is inside, marching begins at the eye. The polyhedron is +// evaluated in scene space and is not clipped by the container bounds. + +#include +using namespace metal; + +struct GreatDodecaheadrollUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct GreatDodecaheadrollVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float GD_PI = 3.14159265359f; +static constant float GD_SIN36 = 0.58778525229f; +static constant float GD_COS36 = 0.80901699437f; +static constant float GD_SIN72 = 0.95105651629f; +static constant float GD_COS72 = 0.30901699437f; +static constant float3 GD_BOX_HALF = float3(1.0f); + +vertex GreatDodecaheadrollVertexOut greatDodecaheadrollVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant GreatDodecaheadrollUniforms &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; + + GreatDodecaheadrollVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 gdRotate(float2 p, float angle) { + float c = cos(angle); + float s = sin(angle); + return float2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +static float pyr(float3 p, float incline, float radius) { + float d12 = 0.0f; + p.z = abs(p.z); + float o = p.y * incline; + + d12 = max(d12, p.z * GD_SIN72 + abs(p.x * GD_COS72 + o)); + d12 = max(d12, p.z * GD_SIN36 + abs(p.x * GD_COS36 - o)); + d12 = max(d12, abs(p.x + o)) - radius; + return d12 / 1.4142f; +} + +static float gdMap(float3 p, float time) { + const float incline = 0.5f; + const float radius = 1.0f; + + float angleX = time * 0.25f; + float angleY = angleX; + + p.yz = gdRotate(p.yz, angleY); + p.xy = gdRotate(p.xy, angleX); + + float d12 = pyr(p, incline, radius); + float nextDistance; + float3 p0; + p.x = -p.x; + + for (int ki = 0; ki < 5; ++ki) { + float k = float(ki); + p0 = p; + p.xz = gdRotate(p.xz, GD_PI / 2.5f * k); + p.xy = gdRotate(p.xy, GD_PI * -0.352416382f); + nextDistance = pyr(p, incline, radius); + d12 = min(d12, nextDistance); + p = p0; + } + + return d12; +} + +static float3 gdNormal(float3 p, float time) { + float2 e = float2(1.0e-2f, 0.0f); + float d = gdMap(p, time); + float3 n = d - float3( + gdMap(p - e.xyy, time), + gdMap(p - e.yxy, time), + gdMap(p - e.yyx, time)); + return normalize(n); +} + +static float2 gdBoxIntersect(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 gdFaceUV(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 gdEnvironment(float3 rd, float time) { + rd = normalize(rd); + float skyMix = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float horizon = pow(max(1.0f - abs(rd.y), 0.0f), 4.0f); + float sun = pow(max(dot(rd, normalize(float3(0.4f, 0.5f, -0.7f))), 0.0f), 40.0f); + float shimmer = 0.5f + 0.5f * sin((rd.x - rd.z) * 9.0f + time * 0.4f); + float3 sky = mix(float3(0.02f, 0.03f, 0.05f), float3(0.16f, 0.2f, 0.28f), skyMix); + sky += float3(0.9f, 0.55f, 0.35f) * horizon * 0.35f * shimmer; + sky += float3(1.0f, 0.92f, 0.74f) * sun; + return sky; +} + +fragment float4 greatDodecaheadrollFragment( + GreatDodecaheadrollVertexOut in [[stage_in]], + constant GreatDodecaheadrollUniforms &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) < GD_BOX_HALF - 1.0e-3f); + float2 tOuter = gdBoxIntersect(eye, rd, GD_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + const float sceneScale = 1.75f; + float3 ro = (eye + rd * (tStart + 0.001f)) * sceneScale; + float3 marchDir = normalize(rd); + + float3 col = float3(0.0f); + float3 p = ro; + float at = 0.0f; + float side = 1.0f; + int stepsTaken = 0; + + for (int i = 0; i < 80; ++i) { + float d = gdMap(p, uniforms.time) * side; + stepsTaken = i; + + if (d < 1.0e-3f) { + float3 n = gdNormal(p, uniforms.time) * side; + float3 l = normalize(ro - float3(5.0f)); + float3 r = normalize(marchDir); + if (dot(l, n) < 0.0f) { + l = -l; + } + + float3 h = normalize(l - r); + float fres = pow(1.0f - max(0.0f, dot(-marchDir, n)), 5.0f); + float diff = pow(max(0.0f, dot(l, n)), 4.0f); + + col += diff * ( + 1.8f * pow(max(0.0f, dot(h, n)), 12.0f) + + 1.6f * pow(max(0.0f, dot(marchDir, n)), 18.0f)); + col += 0.5f * fres; + + side *= -1.0f; + float eta = 1.0f + 0.45f * side; + float3 refracted = refract(marchDir, n, eta); + if (length_squared(refracted) < 1.0e-8f) { + refracted = reflect(marchDir, n); + } + marchDir = normalize(refracted); + d = 9.0e-2f; + } + + if (d > 20.0f) { + break; + } + + p += marchDir * d; + at += 0.01f / max(d, 1.0e-3f); + } + + float3 tint = float3(-marchDir * cos(uniforms.time / 5.0f) - 0.5f); + col += at * 0.001f + float(stepsTaken) / 800.0f; + col = mix(col, tint * 1.25f, 0.4f); + + if (stepsTaken < 2 && gdMap(ro, uniforms.time) > 0.3f) { + float3 env = gdEnvironment(marchDir, uniforms.time); + float2 faceUV = gdFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.25f * dot(faceUV, faceUV); + col += env * vignette * 0.35f; + } + + return float4(clamp(col * 1.33f, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollTypes.swift b/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollTypes.swift new file mode 100644 index 0000000..4c29f24 --- /dev/null +++ b/vr-dive/Demos/GreatDodecaheadroll/GreatDodecaheadrollTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct GreatDodecaheadrollUniforms in GreatDodecaheadrollShaders.metal. +struct GreatDodecaheadrollUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeRenderer.swift b/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeRenderer.swift new file mode 100644 index 0000000..3a7b865 --- /dev/null +++ b/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// GyroidEchoCubeRenderer.swift +// +// Original implementation for a cube-portal reflective gyroid scene. +// Visual inspiration requested from ShaderToy tXtyW8: +// https://www.shadertoy.com/view/tXtyW8 +// This implementation is original and does not reuse source code from the +// reference shader. + +final class GyroidEchoCubeRenderer: VisualPatternController { + let identifier: VisualPatternKind = .gyroidEchoCube + 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.65 + 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 = GyroidEchoCubeRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try GyroidEchoCubeRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = GyroidEchoCubeRenderer.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 = GyroidEchoCubeUniforms( + 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 GyroidEchoCubeRenderer { + 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: "gyroidEchoCubeVertex") + desc.fragmentFunction = library.makeFunction(name: "gyroidEchoCubeFragment") + 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/GyroidEchoCube/GyroidEchoCubeShaders.metal b/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeShaders.metal new file mode 100644 index 0000000..6d8286c --- /dev/null +++ b/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeShaders.metal @@ -0,0 +1,227 @@ +// GyroidEchoCubeShaders.metal +// +// Original cube-portal reflective gyroid scene. +// Visual inspiration requested from ShaderToy tXtyW8: +// https://www.shadertoy.com/view/tXtyW8 +// This implementation is original and does not reuse source code from the +// reference shader. + +#include +using namespace metal; + +#define GEC_FAR 28.0f +#define GEC_PI 3.14159265f +#define GEC_MAX_STEPS 84 +#define GEC_BOUNCES 2 +#define GEC_SCENE_SCALE 10.0f + +struct GyroidEchoCubeUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct GyroidEchoCubeVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct GecHit { + float dist; + int material; +}; + +vertex GyroidEchoCubeVertexOut gyroidEchoCubeVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant GyroidEchoCubeUniforms &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; + + GyroidEchoCubeVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3x3 gec_lookAt(float3 dir) { + float3 up = float3(0.0f, 1.0f, 0.0f); + float3 rt = normalize(cross(dir, up)); + float3 uu = cross(rt, dir); + return float3x3(rt, uu, dir); +} + +static float gec_gyroid(float3 p) { + return dot(cos(p), sin(p.zxy)) + 0.85f; +} + +static GecHit gec_map(float3 p) { + GecHit hit; + hit.dist = 1e5f; + hit.material = 0; + + float d0 = gec_gyroid(p); + if (d0 < hit.dist) { + hit.dist = d0; + hit.material = 1; + } + + float d1 = gec_gyroid(p - float3(0.0f, 0.0f, GEC_PI)); + if (d1 < hit.dist) { + hit.dist = d1; + hit.material = 2; + } + + return hit; +} + +static GecHit gec_raymarch(float3 ro, float3 rd) { + GecHit result; + result.dist = 0.0f; + result.material = 0; + for (int i = 0; i < GEC_MAX_STEPS; ++i) { + GecHit sample = gec_map(ro + rd * result.dist); + if (abs(sample.dist) < 0.001f) { + result.material = sample.material; + break; + } + result.dist += clamp(sample.dist * 0.82f, 0.01f, 0.6f); + result.material = sample.material; + if (result.dist > GEC_FAR) { break; } + } + return result; +} + +static float gec_ao(float3 p, float3 sn) { + float occ = 0.0f; + for (int i = 0; i < 4; ++i) { + float t = float(i) * 0.08f; + float d = gec_map(p + sn * t).dist; + occ += t - d; + } + return clamp(1.0f - occ, 0.0f, 1.0f); +} + +static float3 gec_normal(float3 p) { + float2 e = float2(0.5773f, -0.5773f) * 0.001f; + return normalize( + e.xyy * gec_map(p + e.xyy).dist + + e.yyx * gec_map(p + e.yyx).dist + + e.yxy * gec_map(p + e.yxy).dist + + e.xxx * gec_map(p + e.xxx).dist); +} + +static float3 gec_trace(float3 ro, float3 rd, float time) { + float3 color = float3(0.0f); + float3 throughput = float3(1.0f); + + for (int bounce = 0; bounce < GEC_BOUNCES; ++bounce) { + GecHit hit = gec_raymarch(ro, rd); + if (hit.dist > GEC_FAR) { + float skyMix = clamp(0.5f + 0.5f * rd.y, 0.0f, 1.0f); + float3 sky = mix(float3(0.08f, 0.06f, 0.12f), float3(0.25f, 0.16f, 0.30f), skyMix); + color += throughput * sky; + break; + } + + float fog = 1.0f - exp(-0.010f * hit.dist * hit.dist); + throughput *= 1.0f - fog; + + float3 p = ro + rd * hit.dist; + float3 sn = normalize(gec_normal(p) + pow(abs(cos(p * 48.0f)), float3(16.0f)) * 0.08f); + + float3 lp = float3(10.0f, -10.0f, -10.0f + ro.z); + float3 ld = normalize(lp - p); + float diff = max(0.0f, 0.45f + 1.7f * dot(sn, ld)); + float diff2 = pow(length(sin(sn * 2.1f) * 0.5f + 0.5f), 2.0f); + float diff3 = max(0.0f, 0.5f + 0.5f * dot(sn, float3(0.0f, 1.0f, 0.0f))); + + float spec = max(0.0f, dot(reflect(-ld, sn), -rd)); + float fres = 1.0f - max(0.0f, dot(-rd, sn)); + float freck = dot(cos(p * 23.0f), float3(1.0f)); + + float3 light = float3(0.0f); + light += float3(0.4f, 0.6f, 0.9f) * diff; + light += float3(0.5f, 0.1f, 0.1f) * diff2; + light += float3(0.9f, 0.1f, 0.4f) * diff3; + light += float3(0.18f, 0.16f, 0.18f) * pow(spec, 6.0f) * 2.2f; + + float3 albedo = float3(0.0f); + if (hit.material == 1) { + albedo = float3(0.2f, 0.1f, 0.9f) * max(0.6f, step(2.5f, freck)); + } else { + albedo = float3(0.6f, 0.3f, 0.1f) * max(0.8f, step(-2.5f, freck)); + } + + float ao = gec_ao(p, sn); + float3 localColor = light * albedo * ao; + localColor += albedo * (0.08f + 0.18f * diff3); + color += throughput * localColor; + + rd = reflect(rd, sn); + ro = p + sn * 0.012f; + throughput *= 0.22f + 0.28f * fres; + } + + return color; +} + +static bool gec_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 gyroidEchoCubeFragment( + GyroidEchoCubeVertexOut in [[stage_in]], + constant GyroidEchoCubeUniforms &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 (!gec_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float time = uniforms.time * uniforms.travelSpeed; + float3 ro = (roLocal + rdLocal * max(tEntry, 0.0f)) * GEC_SCENE_SCALE; + float3 rd = rdLocal; + + rd.xy = float2( + cos(sin(time * 0.2f)) * rd.x - sin(sin(time * 0.2f)) * rd.y, + sin(sin(time * 0.2f)) * rd.x + cos(sin(time * 0.2f)) * rd.y); + float3 ta = float3(cos(time * 0.4f), sin(time * 0.4f), 4.0f); + rd = gec_lookAt(normalize(ta)) * rd; + + float3 color = gec_trace(float3(GEC_PI * 0.5f, 0.0f, -time * 5.0f), rd, time); + color = pow(max(color, 0.0f), float3(0.4545f)); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeTypes.swift b/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeTypes.swift new file mode 100644 index 0000000..99e8acf --- /dev/null +++ b/vr-dive/Demos/GyroidEchoCube/GyroidEchoCubeTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct GyroidEchoCubeUniforms in +/// GyroidEchoCubeShaders.metal. +struct GyroidEchoCubeUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Huashan/HuashanRenderer.swift b/vr-dive/Demos/Huashan/HuashanRenderer.swift new file mode 100644 index 0000000..e1a1aee --- /dev/null +++ b/vr-dive/Demos/Huashan/HuashanRenderer.swift @@ -0,0 +1,914 @@ +import Foundation +import Metal +import simd + +private struct HuashanPackedPosition { + var x: Float + var y: Float + var z: Float + + var simd: SIMD3 { SIMD3(x, y, z) } +} + +private struct HuashanChunkKey: Hashable { + var x: Int32 + var y: Int32 + var z: Int32 +} + +private struct HuashanSplatChunk { + var startIndex: Int + var count: Int + var center: SIMD3 + var radius: Float +} + +final class HuashanSplatRenderer: VisualPatternController { + let identifier: VisualPatternKind = .huashan + let preferredClearColor = MTLClearColor(red: 0.05, green: 0.05, blue: 0.1, alpha: 1) + private static let maxSampledSplatCount = 550_000 + private static let maxVisibleSplatCount = 400_000 + private static let warmStartVisibleSplatCount = 120_000 + private static let minPerChunkQuota = 96 + private static let refillBatchSize = 32 + private static let minVisibleCosine: Float = 0.35 + private static let minVisibleCosine2: Float = minVisibleCosine * minVisibleCosine + private static let maxVisibleDistanceScene: Float = 3.6 / 0.26 + private static let maxVisibleDistanceScene2: Float = + maxVisibleDistanceScene * maxVisibleDistanceScene + private static let chunkCellSizeScene: Float = 0.22 / 0.26 + + // MARK: - GPU resources + private let splatBuffer: MTLBuffer // SplatPoint × N (read-only) + private let sortedIndexBuffers: [MTLBuffer] // triple-buffered uint32 × visibleCapacity + private let renderPipelineState: MTLRenderPipelineState + private let computePipelineState: MTLComputePipelineState + private let depthStencilState: MTLDepthStencilState + private let precompBuffer: MTLBuffer // SplatPrecomp × visibleCapacity (written by compute, read by vertex) + private let splatCount: Int + private var renderCount: Int + private let visibleCapacity: Int + private var renderStride: UInt32 + private let maxViewCount: Int + private var currentSampleRatio: Float = PatternCoordinator.defaultHuashanSampleRatio + + // MARK: - Sort state + private var currentSortBuf = 0 // which buffer the GPU is currently reading + private var gpuSortBuf = 0 // which buffer most recently filled by CPU sort + private var sortAvailable = false // true once first sort completes + private let sortQueue = DispatchQueue(label: "vr-dive.huashan.sort", qos: .userInitiated) + private var sortInFlight = false + private var lastSortedCameraPos: SIMD3 = SIMD3(repeating: .greatestFiniteMagnitude) + private var lastSortedCameraFwd: SIMD3 = SIMD3(0, 0, -1) + private var lastSortTime: CFTimeInterval = 0 + private var sortedVisibleCounts: [Int] = [] + private var sortedVisibleChunkCounts: [Int] = [] + private var sortedBudgetHits: [Bool] = [] + private var sortedSortDurationsMS: [Float] = [] + private var lastDiagnosticsLogTime: CFTimeInterval = 0 + // Camera state for sort (updated each frame from render thread, read on sort thread) + private var sortCameraPos: SIMD3 = .zero + private var sortCameraFwd: SIMD3 = SIMD3(0, 0, -1) + private let sortLock = NSLock() + // Sampled positions/indices extracted once at load time for fast sort + private var sampledSplatPositions: [HuashanPackedPosition] + private var sampledSplatIndices: [UInt32] + private var sampleChunks: [HuashanSplatChunk] + private var sortDepths: [Float] + private var sortOrder: [Int] + private var sortScratchIndices: [UInt32] + private var chunkDepths: [Float] + private var chunkOrder: [Int] + private var chunkVisibleCounts: [Int] + private var chunkVisibleOffsets: [Int] + + // MARK: - Scene placement + // Translates + scales the splat cloud so it appears in front of and below the user. + // Adjust tx/ty/tz/s to tune initial viewing position. + private let sceneTransform: simd_float4x4 // scene-space → world-space + private let sceneInverse: simd_float4x4 // world-space → scene-space + + // MARK: - Init + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + // Load .splat file from app bundle + guard let url = Bundle.main.url(forResource: "huashan", withExtension: "splat") else { + throw NSError( + domain: "Huashan", code: 1, + userInfo: [NSLocalizedDescriptionKey: "huashan.splat not found in bundle"]) + } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + + let recordStride = MemoryLayout.stride // 32 bytes + let count = data.count / recordStride + guard count > 0 else { + throw NSError( + domain: "Huashan", code: 2, + userInfo: [NSLocalizedDescriptionKey: "huashan.splat is empty"]) + } + self.splatCount = count + + // Upload splat data to GPU (shared storage for CPU access during sort) + guard + let buf = device.makeBuffer( + bytes: (data as NSData).bytes, + length: count * recordStride, + options: .storageModeShared) + else { + throw NSError( + domain: "Huashan", code: 3, + userInfo: [NSLocalizedDescriptionKey: "Failed to allocate splat buffer"]) + } + self.splatBuffer = buf + + // Layout diagnostic — helps detect Swift/Metal struct padding mismatch + print( + "[Huashan] PerEyeUniforms stride=\(MemoryLayout.stride) HuashanUniforms stride=\(MemoryLayout.stride) (Metal expects PerEye=160, splatCount at offset 320)" + ) + print("[Huashan] SplatPrecomp stride=\(MemoryLayout.stride) (expect 64)") + + let initialSamples = Self.buildSampleSet( + rawPtr: buf.contents().assumingMemoryBound(to: HuashanSplatPoint.self), + splatCount: count, + sampleRatio: currentSampleRatio) + self.renderCount = initialSamples.positions.count + self.visibleCapacity = HuashanSplatRenderer.maxVisibleSplatCount + self.sampledSplatPositions = initialSamples.positions + self.sampledSplatIndices = initialSamples.indices + self.sampleChunks = initialSamples.chunks + self.renderStride = initialSamples.renderStride + self.sortDepths = [Float](repeating: 0, count: renderCount) + self.sortOrder = Array(0...stride + guard let pcBuf = device.makeBuffer(length: precompSize, options: .storageModePrivate) else { + throw NSError( + domain: "Huashan", code: 6, + userInfo: [NSLocalizedDescriptionKey: "Failed to allocate precomp buffer"]) + } + self.precompBuffer = pcBuf + + // Triple-buffered sort index buffers + let idxSize = visibleCapacity * MemoryLayout.stride + var sortBufs = [MTLBuffer]() + for _ in 0..<3 { + guard let b = device.makeBuffer(length: idxSize, options: .storageModeShared) else { + throw NSError( + domain: "Huashan", code: 4, + userInfo: [NSLocalizedDescriptionKey: "Failed to allocate sort buffer"]) + } + // Default: sequential sampled order (no sort yet) + let ptr = b.contents().assumingMemoryBound(to: UInt32.self) + for i in 0..(-0.15462685, 0.09616375, 0.07551384) + let centerShift = simd_float4x4( + columns: ( + SIMD4(1, 0, 0, 0), + SIMD4(0, 1, 0, 0), + SIMD4(0, 0, 1, 0), + SIMD4(-dataCenter.x, -dataCenter.y, -dataCenter.z, 1) + )) + let scale: Float = 0.26 + let scaleMatrix = simd_float4x4( + columns: ( + SIMD4(scale, 0, 0, 0), + SIMD4(0, scale, 0, 0), + SIMD4(0, 0, scale, 0), + SIMD4(0, 0, 0, 1) + )) + let placeInFront = simd_float4x4( + columns: ( + SIMD4(1, 0, 0, 0), + SIMD4(0, 1, 0, 0), + SIMD4(0, 0, 1, 0), + SIMD4(0.0, -0.03, -1.25, 1) + )) + sceneTransform = placeInFront * scaleMatrix * centerShift + sceneInverse = simd_inverse(sceneTransform) + + // Depth stencil: always pass (3DGS is sorted back-to-front; depth test would break blending) + // Write depth at splat center so compositor detects rendered content. + let dsd = MTLDepthStencilDescriptor() + dsd.depthCompareFunction = .always + dsd.isDepthWriteEnabled = true + guard let dss = device.makeDepthStencilState(descriptor: dsd) else { + throw NSError( + domain: "Huashan", code: 5, + userInfo: [NSLocalizedDescriptionKey: "Failed to create depth stencil"]) + } + self.depthStencilState = dss + + // Render + compute pipelines + let pipelines = try HuashanSplatRenderer.makePipelines( + device: device, library: library, maxViewCount: self.maxViewCount) + self.renderPipelineState = pipelines.render + self.computePipelineState = pipelines.compute + } + + // MARK: - VisualPatternController + + func synchronizeState(_ context: PatternSimulationContext) { + let targetRatio = min( + max(context.huashanSampleRatio, PatternCoordinator.minHuashanSampleRatio), + PatternCoordinator.maxHuashanSampleRatio) + guard abs(targetRatio - currentSampleRatio) > 0.001 else { return } + + sortLock.lock() + let rebuildBlocked = sortInFlight + sortLock.unlock() + guard !rebuildBlocked else { return } + + rebuildSampleSet(sampleRatio: targetRatio) + } + + func resetToInitialState() {} + + func updateSimulation(_ context: PatternSimulationContext) { + // Nothing to simulate — the scene is static, only sorting needed + } + + // MARK: - encodeComputePrepass + func encodeComputePrepass(commandBuffer: MTLCommandBuffer, context: PatternRenderContext) { + guard splatCount > 0 else { return } + let sortState = currentSortState() + guard sortState.visibleCount > 0 else { return } + var uniforms = makeUniforms(context: context) + guard let encoder = commandBuffer.makeComputeCommandEncoder() else { return } + let sortBuf = sortedIndexBuffers[sortState.bufferIndex] + encoder.setComputePipelineState(computePipelineState) + encoder.setBuffer(splatBuffer, offset: 0, index: 0) + encoder.setBytes(&uniforms, length: MemoryLayout.stride, index: 1) + encoder.setBuffer(precompBuffer, offset: 0, index: 2) + encoder.setBuffer(sortBuf, offset: 0, index: 3) + let w = min(computePipelineState.maxTotalThreadsPerThreadgroup, 256) + encoder.dispatchThreadgroups( + MTLSize(width: (sortState.visibleCount + w - 1) / w, height: 1, depth: 1), + threadsPerThreadgroup: MTLSize(width: w, height: 1, depth: 1)) + encoder.endEncoding() + } + + // MARK: - encodeFrame + private var diagFrameCount = 0 + func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) { + guard splatCount > 0 else { return } + + // Always update camera and kick background sort, even before first sort completes. + let viewData = context.viewData + let v2w0 = viewData.viewToWorldTransforms[0] + let camPosWorld = SIMD3(v2w0.columns.3.x, v2w0.columns.3.y, v2w0.columns.3.z) + let fwdWorld4 = v2w0 * SIMD4(0, 0, -1, 0) + let camPos4 = sceneInverse * SIMD4(camPosWorld.x, camPosWorld.y, camPosWorld.z, 1) + let camPos = SIMD3(camPos4.x, camPos4.y, camPos4.z) + let camFwd4 = sceneInverse * SIMD4(fwdWorld4.x, fwdWorld4.y, fwdWorld4.z, 0) + let camFwd = normalize(SIMD3(camFwd4.x, camFwd4.y, camFwd4.z)) + triggerSortIfNeeded(cameraPos: camPos, cameraForward: camFwd) + + var uniforms = makeUniforms(context: context) + let sortState = currentSortState() + logDiagnosticsIfNeeded(sortState: sortState, now: CFAbsoluteTimeGetCurrent()) + guard sortState.visibleCount > 0 else { return } + + diagFrameCount += 1 + if diagFrameCount == 2 { + let centerClip = uniforms.eye0.vpMatrix * SIMD4(0, 0, 0, 1) + let ndc = SIMD3( + centerClip.x / centerClip.w, centerClip.y / centerClip.w, centerClip.z / centerClip.w) + print( + "[Huashan] scene-origin ndc=\(ndc) splatCount=\(splatCount) renderCount=\(renderCount) visibleCount=\(sortState.visibleCount) visibleCapacity=\(visibleCapacity) visibleChunks=\(sortState.visibleChunkCount) chunks=\(sampleChunks.count) stride=\(renderStride) sampleRatio=\(Int((currentSampleRatio * 100).rounded()))%" + ) + } + + encoder.setRenderPipelineState(renderPipelineState) + encoder.setDepthStencilState(depthStencilState) + encoder.setCullMode(.none) + context.applyViewConfiguration(on: encoder) + + encoder.setVertexBuffer(precompBuffer, offset: 0, index: 0) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1) + + encoder.drawPrimitives( + type: .triangle, + vertexStart: 0, + vertexCount: 6, + instanceCount: sortState.visibleCount) + } + + // MARK: - Uniform builder + private func makeUniforms(context: PatternRenderContext) -> HuashanUniforms { + let viewData = context.viewData + let eyeCount = min(viewData.viewCount, maxViewCount) + func makeEye(_ i: Int) -> HuashanPerEyeUniforms { + let idx = min(i, eyeCount - 1) + let vp = viewData.viewProjectionMatrices[idx] + let v2w = viewData.viewToWorldTransforms[idx] + // Use renderTarget size for focal so r1 comes out in physical pixels + let rtW = Float(context.renderTargetWidth) + let rtH = Float(context.renderTargetHeight) + let p = vp * v2w + return HuashanPerEyeUniforms( + vpMatrix: vp * sceneTransform, + viewMatrix: v2w.inverse * sceneTransform, + focalXY: SIMD2(abs(p[0][0] * rtW * 0.5), abs(p[1][1] * rtH * 0.5)), + viewportSize: SIMD2(rtW, rtH), + renderTargetSize: SIMD2(rtW, rtH) + ) + } + return HuashanUniforms( + eye0: makeEye(0), eye1: makeEye(1), + splatCount: UInt32(splatCount), viewCount: UInt32(eyeCount), + splatScale: 1.0, _pad: 0) + } + + // MARK: - Background sort + private func triggerSortIfNeeded(cameraPos: SIMD3, cameraForward: SIMD3) { + let now = CFAbsoluteTimeGetCurrent() + let movement = simd_length(cameraPos - lastSortedCameraPos) + let forwardDot = simd_dot(cameraForward, lastSortedCameraFwd) + let needsFirstSort = !sortAvailable + let heavySampleSet = renderCount >= 320_000 + let movementThreshold: Float = heavySampleSet ? 0.54 : 0.26 + let forwardThreshold: Float = heavySampleSet ? 0.955 : 0.98 + let minSortInterval: CFTimeInterval = heavySampleSet ? 0.90 : 0.40 + let needsResort = movement > movementThreshold || forwardDot < forwardThreshold + if !needsFirstSort && (!needsResort || now - lastSortTime < minSortInterval) { + return + } + + sortLock.lock() + sortCameraPos = cameraPos + sortCameraFwd = cameraForward + let alreadyRunning = sortInFlight + sortLock.unlock() + + guard !alreadyRunning else { return } + + sortLock.lock() + sortInFlight = true + let pos = sortCameraPos + let fwd = sortCameraFwd + sortLock.unlock() + + let positions = self.sampledSplatPositions + let sampledIndices = self.sampledSplatIndices + let sampleChunks = self.sampleChunks + let visibleCapacity = self.visibleCapacity + + // Pick the buffer that neither the GPU nor previous sort is using + let writeBuf = (gpuSortBuf + 1) % 3 + let targetBuf = self.sortedIndexBuffers[writeBuf] + + sortQueue.async { [weak self] in + guard let self else { return } + let sortStart = CFAbsoluteTimeGetCurrent() + + // Chunk coarse culling trims most splats before fine depth sorting. + var visibleChunkCount = 0 + for chunkIndex in sampleChunks.indices { + let chunk = sampleChunks[chunkIndex] + let dx = chunk.center.x - pos.x + let dy = chunk.center.y - pos.y + let dz = chunk.center.z - pos.z + let dist2 = dx * dx + dy * dy + dz * dz + let dist = sqrt(max(dist2, 1e-4)) + let depth = dx * fwd.x + dy * fwd.y + dz * fwd.z + if depth + chunk.radius <= 0.0 { + continue + } + if dist - chunk.radius > HuashanSplatRenderer.maxVisibleDistanceScene { + continue + } + if (depth + chunk.radius) / dist < HuashanSplatRenderer.minVisibleCosine { + continue + } + + self.chunkDepths[chunkIndex] = depth + self.chunkOrder[visibleChunkCount] = chunkIndex + visibleChunkCount += 1 + } + + // Sort chunks back-to-front, then locally sort splats inside each chunk. + // This avoids one global 200k-element sort while preserving approximate blend order. + self.chunkOrder[0.. self.chunkDepths[$1] } + + var localVisibleTotal = 0 + var maxChunkVisibleCount = 0 + for chunkListIndex in 0.. maxDist^2 → equivalent to dist > maxDist (avoids sqrt) + if dist2 > HuashanSplatRenderer.maxVisibleDistanceScene2 { + continue + } + // depth/dist < minCosine → depth^2 < dist2*minCosine^2 (valid when depth > 0) + if depth * depth < dist2 * HuashanSplatRenderer.minVisibleCosine2 { + continue + } + + self.sortDepths[sampleIndex] = depth + self.sortOrder[localVisibleTotal + chunkVisibleCount] = sampleIndex + chunkVisibleCount += 1 + } + + self.chunkVisibleCounts[chunkIndex] = chunkVisibleCount + localVisibleTotal += chunkVisibleCount + maxChunkVisibleCount = max(maxChunkVisibleCount, chunkVisibleCount) + } + + let perChunkQuota = + visibleChunkCount > 0 + ? min( + max(HuashanSplatRenderer.minPerChunkQuota, visibleCapacity / visibleChunkCount), + visibleCapacity) + : visibleCapacity + var visibleCount = 0 + var budgetHit = false + + // First pass: guarantee each visible chunk a modest quota to keep the full silhouette. + for chunkListIndex in 0.. 0 else { continue } + + let base = self.chunkVisibleOffsets[chunkIndex] + self.sortOrder[base..<(base + chunkVisibleCount)].sort { + self.sortDepths[$0] > self.sortDepths[$1] + } + + let emitCount = min(perChunkQuota, chunkVisibleCount, visibleCapacity - visibleCount) + guard emitCount > 0 else { + budgetHit = true + break + } + + for localIndex in 0..= visibleCapacity { + budgetHit = true + break + } + } + + // Second pass: rotate extra budget across chunks in small batches instead of + // draining one chunk at a time, so distant silhouette chunks continue to contribute. + if visibleCount < visibleCapacity { + var refillRemaining = true + while visibleCount < visibleCapacity && refillRemaining { + refillRemaining = false + refillLoop: for chunkListIndex in 0.. 0 else { continue } + + let base = self.chunkVisibleOffsets[chunkIndex] - min(perChunkQuota, chunkVisibleCount) + let resume = self.chunkVisibleOffsets[chunkIndex] + let chunkEnd = base + chunkVisibleCount + guard resume < chunkEnd else { continue } + + refillRemaining = true + let batchEnd = min(resume + HuashanSplatRenderer.refillBatchSize, chunkEnd) + for localIndex in resume..= visibleCapacity { + budgetHit = true + self.chunkVisibleOffsets[chunkIndex] = localIndex + 1 + break refillLoop + } + } + self.chunkVisibleOffsets[chunkIndex] = batchEnd + } + } + + if visibleCount >= visibleCapacity { + budgetHit = true + } + } + + // Copy to GPU buffer + _ = self.sortScratchIndices.withUnsafeBytes { rawBytes in + memcpy( + targetBuf.contents(), + rawBytes.baseAddress!, + visibleCount * MemoryLayout.stride) + } + + // Atomically publish the new buffer + self.sortLock.lock() + self.gpuSortBuf = writeBuf + self.sortedVisibleCounts[writeBuf] = visibleCount + self.sortedVisibleChunkCounts[writeBuf] = visibleChunkCount + self.sortedBudgetHits[writeBuf] = budgetHit + self.sortedSortDurationsMS[writeBuf] = Float( + (CFAbsoluteTimeGetCurrent() - sortStart) * 1000.0) + self.sortAvailable = true + self.sortInFlight = false + self.lastSortedCameraPos = pos + self.lastSortedCameraFwd = fwd + self.lastSortTime = now + self.sortLock.unlock() + } + } + + private func currentSortState() -> ( + bufferIndex: Int, + visibleCount: Int, + visibleChunkCount: Int, + budgetHit: Bool, + sortDurationMS: Float + ) { + sortLock.lock() + let bufferIndex = sortAvailable ? gpuSortBuf : 0 + let visibleCount = + sortedVisibleCounts.isEmpty ? visibleCapacity : sortedVisibleCounts[bufferIndex] + let visibleChunkCount = + sortedVisibleChunkCounts.isEmpty ? sampleChunks.count : sortedVisibleChunkCounts[bufferIndex] + let budgetHit = sortedBudgetHits.isEmpty ? false : sortedBudgetHits[bufferIndex] + let sortDurationMS = sortedSortDurationsMS.isEmpty ? 0 : sortedSortDurationsMS[bufferIndex] + sortLock.unlock() + return (bufferIndex, visibleCount, visibleChunkCount, budgetHit, sortDurationMS) + } + + private func logDiagnosticsIfNeeded( + sortState: ( + bufferIndex: Int, + visibleCount: Int, + visibleChunkCount: Int, + budgetHit: Bool, + sortDurationMS: Float + ), + now: CFTimeInterval + ) { + guard now - lastDiagnosticsLogTime >= 2.0 else { return } + lastDiagnosticsLogTime = now + + let utilization = + visibleCapacity > 0 + ? Float(sortState.visibleCount) / Float(visibleCapacity) + : 0 + let utilizationPct = Int((utilization * 100).rounded()) + let sortDurationText = String(format: "%.2f", sortState.sortDurationMS) + print( + "[Huashan] sampleRatio=\(Int((currentSampleRatio * 100).rounded()))% visibleChunks=\(sortState.visibleChunkCount)/\(sampleChunks.count) visibleSplats=\(sortState.visibleCount)/\(visibleCapacity) util=\(utilizationPct)% budgetHit=\(sortState.budgetHit) sort=\(sortDurationText)ms" + ) + } + + private func rebuildSampleSet(sampleRatio: Float) { + let rebuiltSamples = Self.buildSampleSet( + rawPtr: splatBuffer.contents().assumingMemoryBound(to: HuashanSplatPoint.self), + splatCount: splatCount, + sampleRatio: sampleRatio) + let nextVisibleCount = min(visibleCapacity, rebuiltSamples.positions.count) + let warmStartVisibleCount = min( + nextVisibleCount, HuashanSplatRenderer.warmStartVisibleSplatCount) + + sampledSplatPositions = rebuiltSamples.positions + sampledSplatIndices = rebuiltSamples.indices + sampleChunks = rebuiltSamples.chunks + renderCount = rebuiltSamples.positions.count + renderStride = rebuiltSamples.renderStride + currentSampleRatio = sampleRatio + sortDepths = [Float](repeating: 0, count: renderCount) + sortOrder = Array(0.., + splatCount: Int, + sampleRatio: Float + ) -> ( + positions: [HuashanPackedPosition], + indices: [UInt32], + chunks: [HuashanSplatChunk], + renderStride: UInt32 + ) { + let clampedRatio = min( + max(sampleRatio, PatternCoordinator.minHuashanSampleRatio), + PatternCoordinator.maxHuashanSampleRatio) + let rawTargetSampleCount = max(1, Int((Float(splatCount) * clampedRatio).rounded())) + let targetSampleCount = min(rawTargetSampleCount, maxSampledSplatCount) + let keepRatio = min(Float(targetSampleCount) / Float(splatCount), 1.0) + let renderStride = + keepRatio > 0 + ? UInt32(max(1, Int((1.0 / keepRatio).rounded()))) + : UInt32(splatCount) + + if targetSampleCount >= splatCount { + var sampledPositions = [HuashanPackedPosition]() + var sampledIndices = [UInt32]() + sampledPositions.reserveCapacity(splatCount) + sampledIndices.reserveCapacity(splatCount) + + for i in 0.., + splatCount: Int, + targetSampleCount: Int + ) -> (positions: [HuashanPackedPosition], indices: [UInt32], chunks: [HuashanSplatChunk]) { + var buckets: [HuashanChunkKey: [Int]] = [:] + buckets.reserveCapacity(max(1, splatCount / 384)) + + let invCell = 1.0 / chunkCellSizeScene + for sampleIndex in 0..= sortedKeys.count ? 1 : 0 + var sampledCount = 0 + var sourceCountSeen = 0 + + for (chunkListIndex, key) in sortedKeys.enumerated() { + guard let bucket = buckets[key], !bucket.isEmpty else { continue } + + let bucketCount = bucket.count + let remainingBudget = targetSampleCount - sampledCount + if remainingBudget <= 0 { break } + + let remainingSourceCount = max(splatCount - sourceCountSeen, 1) + let takeCount: Int + if chunkListIndex == sortedKeys.count - 1 { + takeCount = min(bucketCount, remainingBudget) + } else { + let proportional = Int( + (Float(bucketCount) * Float(remainingBudget) / Float(remainingSourceCount)).rounded(.down) + ) + takeCount = min(bucketCount, remainingBudget, max(guaranteePerChunk, proportional)) + } + + sourceCountSeen += bucketCount + guard takeCount > 0 else { continue } + + let startIndex = sampledPositions.count + var center = SIMD3(repeating: 0) + for sourceIndex in bucket { + center += SIMD3( + rawPtr[sourceIndex].px, rawPtr[sourceIndex].py, rawPtr[sourceIndex].pz) + } + center /= Float(bucketCount) + + if takeCount >= bucketCount { + for sourceIndex in bucket { + sampledPositions.append( + HuashanPackedPosition( + x: rawPtr[sourceIndex].px, + y: rawPtr[sourceIndex].py, + z: rawPtr[sourceIndex].pz)) + sampledIndices.append(UInt32(sourceIndex)) + } + } else { + let step = Float(bucketCount) / Float(takeCount) + var cursor = step * 0.5 + var lastLocalIndex = -1 + for _ in 0..( + rawPtr[sourceIndex].px, rawPtr[sourceIndex].py, rawPtr[sourceIndex].pz) + radius = max(radius, simd_length(position - center)) + } + + chunks.append( + HuashanSplatChunk( + startIndex: startIndex, + count: sampledPositions.count - startIndex, + center: center, + radius: max(radius, chunkCellSizeScene * 0.5))) + sampledCount = sampledPositions.count + } + + return (positions: sampledPositions, indices: sampledIndices, chunks: chunks) + } + + fileprivate static func buildSpatialChunks( + sampledPositions: [HuashanPackedPosition], + sampledIndices: [UInt32] + ) -> (positions: [HuashanPackedPosition], indices: [UInt32], chunks: [HuashanSplatChunk]) { + var buckets: [HuashanChunkKey: [Int]] = [:] + buckets.reserveCapacity(max(1, sampledPositions.count / 384)) + + for sampleIndex in sampledPositions.indices { + let position = sampledPositions[sampleIndex] + let invCell = 1.0 / chunkCellSizeScene + let key = HuashanChunkKey( + x: Int32(floor(position.x * invCell)), + y: Int32(floor(position.y * invCell)), + z: Int32(floor(position.z * invCell))) + buckets[key, default: []].append(sampleIndex) + } + + let sortedKeys = buckets.keys.sorted { + if $0.z != $1.z { return $0.z < $1.z } + if $0.y != $1.y { return $0.y < $1.y } + return $0.x < $1.x + } + + var reorderedPositions = [HuashanPackedPosition]() + var reorderedIndices = [UInt32]() + var chunks = [HuashanSplatChunk]() + reorderedPositions.reserveCapacity(sampledPositions.count) + reorderedIndices.reserveCapacity(sampledIndices.count) + chunks.reserveCapacity(sortedKeys.count) + + for key in sortedKeys { + guard let bucket = buckets[key], !bucket.isEmpty else { continue } + let startIndex = reorderedPositions.count + var center = SIMD3(repeating: 0) + for sourceIndex in bucket { + let position = sampledPositions[sourceIndex] + reorderedPositions.append(position) + reorderedIndices.append(sampledIndices[sourceIndex]) + center += position.simd + } + + center /= Float(bucket.count) + var radius: Float = 0 + for offset in 0.. (render: MTLRenderPipelineState, compute: MTLComputePipelineState) { + // Render pipeline + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "huashanVertexShader") + desc.fragmentFunction = library.makeFunction(name: "huashanFragmentShader") + desc.colorAttachments[0].pixelFormat = .rgba16Float + desc.depthAttachmentPixelFormat = .depth32Float + desc.inputPrimitiveTopology = .triangle + + let att = desc.colorAttachments[0]! + att.isBlendingEnabled = true + att.rgbBlendOperation = .add + att.alphaBlendOperation = .add + att.sourceRGBBlendFactor = .one + att.sourceAlphaBlendFactor = .one + att.destinationRGBBlendFactor = .oneMinusSourceAlpha + att.destinationAlphaBlendFactor = .oneMinusSourceAlpha + + desc.maxVertexAmplificationCount = max(maxViewCount, 1) + let renderPSO = try device.makeRenderPipelineState(descriptor: desc) + + // Compute pipeline + guard let computeFn = library.makeFunction(name: "huashanComputePrecomp") else { + throw NSError( + domain: "Huashan", code: 7, + userInfo: [NSLocalizedDescriptionKey: "huashanComputePrecomp not found in library"]) + } + let computePSO = try device.makeComputePipelineState(function: computeFn) + + return (render: renderPSO, compute: computePSO) + } +} diff --git a/vr-dive/Demos/Huashan/HuashanShaders.metal b/vr-dive/Demos/Huashan/HuashanShaders.metal new file mode 100644 index 0000000..7e97581 --- /dev/null +++ b/vr-dive/Demos/Huashan/HuashanShaders.metal @@ -0,0 +1,256 @@ +#include +using namespace metal; + +// ── Structs (must match HuashanTypes.swift) ─────────────────────────────────── +struct SplatPoint { + packed_float3 position; // 12 bytes + packed_float3 scale; // 12 bytes + uchar4 colorRGBA; // 4 bytes (r,g,b, opacity) + uchar4 rotWXYZ; // 4 bytes (w,x,y,z each = component*128+128) +}; + +struct PerEyeUniforms { + float4x4 vpMatrix; + float4x4 viewMatrix; + float2 focalXY; + float2 viewportSize; // display viewport (may be larger than texture) + float2 renderTargetSize; // actual texture size +}; + +struct HuashanUniforms { + PerEyeUniforms eye0; + PerEyeUniforms eye1; + uint splatCount; + uint viewCount; + float splatScale; + float _pad; +}; + +// ── Helper: build 3×3 rotation matrix from unit quaternion stored as (x,y,z,w) ─ +inline float3x3 quatToMatrix(float4 q) { + // q.xyzw = (x, y, z, w) — .sog WebP RGBA maps to (x,y,z,w) order + float x = q.x, y = q.y, z = q.z, w = q.w; + float x2 = x+x, y2 = y+y, z2 = z+z; + float wx = w*x2, wy = w*y2, wz = w*z2; + float xx = x*x2, xy = x*y2, xz = x*z2; + float yy = y*y2, yz = y*z2, zz = z*z2; + // column-major float3x3(col0, col1, col2) + return float3x3( + float3(1-(yy+zz), xy+wz, xz-wy), + float3( xy-wz, 1-(xx+zz), yz+wx), + float3( xz+wy, yz-wx, 1-(xx+yy)) + ); +} + +// ── Compute 2D screen-space covariance via Jacobian projection ───────────────── +// Sigma3D: 3×3 world-space covariance +// posView: view-space position of the splat centre +// focalXY: (fx, fy) in pixels +// returns float3(cov00, cov01, cov11) — upper-triangle of 2×2 sym matrix +inline float3 computeCov2D(float3x3 Sigma3D, + float3 posView, + float2 focalXY, + float4x4 viewMatrix) { + // Extract 3×3 rotation part of view matrix (world → view) + float3x3 W = float3x3(viewMatrix[0].xyz, viewMatrix[1].xyz, viewMatrix[2].xyz); + + // View-space covariance: Sigma_v = W * Sigma3D * W^T + float3x3 Sv = W * Sigma3D * transpose(W); + + float tz = posView.z; + float tx = posView.x; + float ty = posView.y; + float tz2 = tz * tz; + + float J00 = focalXY.x / tz; + float J11 = focalXY.y / tz; + float J02 = -focalXY.x * tx / tz2; + float J12 = -focalXY.y * ty / tz2; + + // Sigma2D = J * Sv * J^T (2×2, symmetric → 3 values) + // J = [[J00, 0, J02], [0, J11, J12]] (2×3) + // Using row-vectors of J: + float3 jRow0 = float3(J00, 0.0, J02); + float3 jRow1 = float3(0.0, J11, J12); + + // Sv * J^T: columns are Sv * jRow0^T and Sv * jRow1^T + float3 col0 = Sv * jRow0; // Sv * J^T[:,0] + float3 col1 = Sv * jRow1; // Sv * J^T[:,1] + + float c00 = dot(jRow0, col0); + float c01 = dot(jRow0, col1); + float c11 = dot(jRow1, col1); + + // Small regularizer to prevent degenerate splats + return float3(c00 + 0.3, c01, c11 + 0.3); +} + +// ── Per-splat precomputed data (matches HuashanSplatPrecomp in HuashanTypes.swift) ─ +struct SplatPrecomp { + float4 clipPos0; // clip position eye 0 + float4 clipPos1; // clip position eye 1 + half4 axesU; // xy = NDC axis-U eye0, zw = NDC axis-U eye1 + half4 axesV; // xy = NDC axis-V eye0, zw = NDC axis-V eye1 + half4 color; // pre-multiplied RGBA +}; + +// ── Compute kernel: precompute per-splat 2D covariance for both eyes ────────── +// Runs once per splat per frame (NOT amplified). Results stored in precompBuffer. +// Vertex shader just reads the result — no heavy math in VS. +kernel void huashanComputePrecomp( + uint gid [[thread_position_in_grid]], + const device SplatPoint *splats [[buffer(0)]], + constant HuashanUniforms &uniforms [[buffer(1)]], + device SplatPrecomp *output [[buffer(2)]], + const device uint *sortedIndices [[buffer(3)]]) +{ + // Thread gid handles one entry of the sampled back-to-front sorted splat list. + uint splatIdx = sortedIndices[gid]; + if (splatIdx >= uniforms.splatCount) { return; } + + SplatPrecomp out; + // Default: invisible (behind camera in reverse-Z) + out.clipPos0 = float4(0, 0, -1, 1); + out.clipPos1 = float4(0, 0, -1, 1); + out.axesU = half4(0); + out.axesV = half4(0); + out.color = half4(0); + + SplatPoint sp = splats[splatIdx]; + + float alpha = float(sp.colorRGBA.a) / 255.0; + if (alpha < 0.008) { output[gid] = out; return; } + + float3 srgb = float3(sp.colorRGBA.rgb) / 255.0; + float3 linRGB = pow(max(srgb, 0.0), float3(2.2)); + out.color = half4(float4(linRGB * alpha, alpha)); + + // Most raw scales are already linear, but a tiny fraction are huge outliers. + // Clamp only the tail so we keep real anisotropic detail without giant blob splats. + float4 q; + q.x = (float(sp.rotWXYZ.x) - 128.0) / 128.0; + q.y = (float(sp.rotWXYZ.y) - 128.0) / 128.0; + q.z = (float(sp.rotWXYZ.z) - 128.0) / 128.0; + q.w = (float(sp.rotWXYZ.w) - 128.0) / 128.0; + q = normalize(q); + + float3x3 R = quatToMatrix(q); + float3 sc = clamp(abs(float3(sp.scale)), float3(0.0005), float3(1.15)); + float3x3 S2 = float3x3( + float3(sc.x*sc.x, 0, 0), + float3(0, sc.y*sc.y, 0), + float3(0, 0, sc.z*sc.z)); + float3x3 Sig3 = R * S2 * transpose(R); + + float3 posScene = float3(sp.position); + float maxR = 1.85; + + // Compute for each eye + for (uint eyeIdx = 0; eyeIdx < 2; eyeIdx++) { + PerEyeUniforms eye = (eyeIdx == 0) ? uniforms.eye0 : uniforms.eye1; + + float4 posView4 = eye.viewMatrix * float4(posScene, 1.0); + float3 posView = posView4.xyz; + float viewDepth = -posView.z; + + float4 clipCenter = eye.vpMatrix * float4(posScene, 1.0); + if (clipCenter.w <= 0.0) { continue; } + + float2 clipNDC = clipCenter.xy / clipCenter.w; + float centerDist2 = dot(clipNDC, clipNDC); + + float3 cov2d = computeCov2D(Sig3, posView, eye.focalXY, eye.viewMatrix); + float c00 = cov2d.x, c01 = cov2d.y, c11 = cov2d.z; + + float mid = 0.5 * (c00 + c11); + float disc = sqrt(max(0.25*(c00-c11)*(c00-c11) + c01*c01, 0.0)); + float lam1 = mid + disc; + float lam2 = mid - disc; + float r1 = min(sqrt(max(lam1, 0.0)) * 3.35, maxR); + float r2 = min(sqrt(max(lam2, 0.0)) * 3.35, maxR); + r2 = max(r2, r1 * 0.12); // keep elongated structure but avoid collapsing into hairline splats + r1 = max(r1, 0.028); + r2 = max(r2, 0.012); + + // Stable far-field thinning: keep more peripheral structure now that the visible budget is higher. + uint hash = splatIdx * 1664525u + 1013904223u; + bool peripheral = centerDist2 > 0.40; + bool farPeripheral = centerDist2 > 0.85; + bool edgePeripheral = centerDist2 > 1.40; + if (peripheral && viewDepth > 2.95 && r1 < 0.22 && (hash & 3u) == 3u) { continue; } + if (farPeripheral && viewDepth > 3.20 && r1 < 0.14 && (hash & 7u) == 7u) { continue; } + if (edgePeripheral && viewDepth > 3.45 && r1 < 0.08 && (hash & 15u) == 15u) { continue; } + + float2 v1 = normalize(float2(c01, lam1 - c00) + float2(1e-6)); + float2 v2 = float2(-v1.y, v1.x); + + // Axes in NDC space: axisU/V such that vertex offset = (uv.x*axisU + uv.y*axisV) * w + float2 axisU = v1 * r1 / (eye.renderTargetSize * 0.5); + float2 axisV = v2 * r2 / (eye.renderTargetSize * 0.5); + + if (eyeIdx == 0) { + out.clipPos0 = clipCenter; + out.axesU.xy = half2(axisU); + out.axesV.xy = half2(axisV); + } else { + out.clipPos1 = clipCenter; + out.axesU.zw = half2(axisU); + out.axesV.zw = half2(axisV); + } + } + + output[gid] = out; +} + +// ── Vertex shader output ─────────────────────────────────────────────────────── +struct VertexOut { + float4 clipPos [[position]]; + float2 uv; // normalised ellipse coords, ±1 at 3σ boundary + float4 color; // pre-multiplied RGBA +}; + +// ── Vertex shader: reads precomputed data, trivial billboard projection ─────── +vertex VertexOut huashanVertexShader( + ushort amplificationID [[amplification_id]], + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + const device SplatPrecomp *precomp [[buffer(0)]], + constant HuashanUniforms &uniforms [[buffer(1)]]) +{ + VertexOut out; + out.clipPos = float4(0, 0, -1, 1); + out.uv = float2(0); + out.color = float4(0); + + float2 corners[4] = { float2(-1,-1), float2(1,-1), float2(-1,1), float2(1,1) }; + uint triVtx[6] = { 0, 1, 2, 1, 3, 2 }; + float2 uv = corners[triVtx[vertexID % 6]]; + + if (instanceID >= uniforms.splatCount) { return out; } + + SplatPrecomp sp = precomp[instanceID]; + out.color = float4(sp.color); + if (out.color.a < 0.02) { return out; } + + float4 clipCenter = (amplificationID == 0) ? sp.clipPos0 : sp.clipPos1; + if (clipCenter.w <= 0.0) { return out; } + + float2 axisU = (amplificationID == 0) ? float2(sp.axesU.xy) : float2(sp.axesU.zw); + float2 axisV = (amplificationID == 0) ? float2(sp.axesV.xy) : float2(sp.axesV.zw); + + float2 ndcOffset = (uv.x * axisU + uv.y * axisV) * clipCenter.w; + out.clipPos = clipCenter + float4(ndcOffset, 0.0, 0.0); + out.uv = uv; + return out; +} + +// ── Fragment shader ─────────────────────────────────────────────────────────── +fragment float4 huashanFragmentShader(VertexOut in [[stage_in]]) +{ + float r2 = dot(in.uv, in.uv); + if (r2 > 1.0) { discard_fragment(); } + float gauss = exp(-0.5 * r2 * 6.5); + if (gauss < 0.008) { discard_fragment(); } + return in.color * gauss; +} + diff --git a/vr-dive/Demos/Huashan/HuashanTypes.swift b/vr-dive/Demos/Huashan/HuashanTypes.swift new file mode 100644 index 0000000..2fbe4b3 --- /dev/null +++ b/vr-dive/Demos/Huashan/HuashanTypes.swift @@ -0,0 +1,41 @@ +import Foundation +import simd + +// ─── GPU-side struct (matches .splat binary layout, 32 bytes) ─────────────── +// Uses individual Floats instead of SIMD3 to avoid Swift's 16-byte padding +// that would make the stride 40 instead of 32. Must match SplatPoint in HuashanShaders.metal +struct HuashanSplatPoint { + var px, py, pz: Float // 12 bytes (position) + var sx, sy, sz: Float // 12 bytes (scale, linear) + var colorRGBA: SIMD4 // 4 bytes (sRGB 0-255 + opacity 0-255) + var rotWXYZ: SIMD4 // 4 bytes (xyzw each = component*128+128) +} // stride = 32 ✓ + +// ─── Per-eye uniforms passed to vertex shader ──────────────────────────────── +struct HuashanPerEyeUniforms { + var vpMatrix: simd_float4x4 + var viewMatrix: simd_float4x4 // scene → view + var focalXY: SIMD2 // focal lengths in pixels (render target pixels) + var viewportSize: SIMD2 // display viewport size (may be larger than texture) + var renderTargetSize: SIMD2 // actual texture/render target size in pixels +} + +// ─── Per-splat precomputed data: written by compute shader, read by vertex shader +// Keep clip positions in float for stable reprojection, pack axes/color into half. +struct HuashanSplatPrecomp { + var clipPos0: SIMD4 // clip position eye 0 + var clipPos1: SIMD4 // clip position eye 1 + var axesU: SIMD4 // xy = NDC axis-U eye0, zw = NDC axis-U eye1 + var axesV: SIMD4 // xy = NDC axis-V eye0, zw = NDC axis-V eye1 + var color: SIMD4 // pre-multiplied RGBA (linear) +} // stride = 56 ✓ + +// ─── Global uniforms ───────────────────────────────────────────────────────── +struct HuashanUniforms { + var eye0: HuashanPerEyeUniforms + var eye1: HuashanPerEyeUniforms + var splatCount: UInt32 + var viewCount: UInt32 + var splatScale: Float // global scale multiplier for debugging + var _pad: Float = 0 +} diff --git a/vr-dive/Demos/Huashan/huashan.splat b/vr-dive/Demos/Huashan/huashan.splat new file mode 100644 index 0000000..e36a9e3 Binary files /dev/null and b/vr-dive/Demos/Huashan/huashan.splat differ diff --git a/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetRenderer.swift b/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetRenderer.swift new file mode 100644 index 0000000..c3a975a --- /dev/null +++ b/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetRenderer.swift @@ -0,0 +1,172 @@ +import Metal +import simd + +// HyperbolicGroupLimitSetRenderer.swift +// Source adaptation: Zhao Liang, Shadertoy "Limit set of rank 4 hyperbolic Coxeter groups" +// https://www.shadertoy.com/view/NstSDs +// +// This version renders the scene inside a 2 m cube container so the effect can +// be viewed from any direction in immersive space. + +final class HyperbolicGroupLimitSetRenderer: VisualPatternController { + let identifier: VisualPatternKind = .hyperbolicGroupLimitSet + 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.02, -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 = HyperbolicGroupLimitSetRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try HyperbolicGroupLimitSetRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = HyperbolicGroupLimitSetRenderer.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 = HyperbolicGroupLimitSetUniforms( + 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 HyperbolicGroupLimitSetRenderer { + 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: "hyperbolicGroupLimitSetVertex") + desc.fragmentFunction = library.makeFunction(name: "hyperbolicGroupLimitSetFragment") + 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/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetShaders.metal b/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetShaders.metal new file mode 100644 index 0000000..5fc2c32 --- /dev/null +++ b/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetShaders.metal @@ -0,0 +1,358 @@ +// HyperbolicGroupLimitSetShaders.metal +// Source adaptation: Zhao Liang, Shadertoy "Limit set of rank 4 hyperbolic Coxeter groups" +// https://www.shadertoy.com/view/NstSDs +// +// This version renders the original sphere/plane hyperbolic limit-set scene +// inside a 2 m cube container so the effect can be viewed from any direction +// in immersive space. + +#include +using namespace metal; + +struct HyperbolicGroupLimitSetUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct HGLSVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct HGLSMirrors { + float3 A; + float3 B; + float3 D; + float4 C; +}; + +vertex HGLSVertexOut hyperbolicGroupLimitSetVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant HyperbolicGroupLimitSetUniforms &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; + + HGLSVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static constant float HG_PI = 3.141592653f; +static constant float3 HG_BOX_HALF = float3(1.0f, 1.0f, 1.0f); +static constant float HG_SCENE_SCALE = 0.82f; +static constant int HG_MAX_TRACE_STEPS = 96; +static constant int HG_MAX_REFLECTIONS = 240; +static constant float HG_MIN_TRACE_DIST = 0.0015f; +static constant float HG_PRECISION = 0.00012f; +static constant float3 HG_PQR = float3(3.0f, 3.0f, 7.0f); +static constant float3 HG_CHECKER1 = float3(0.0f, 0.0f, 0.05f); +static constant float3 HG_CHECKER2 = float3(0.2f, 0.2f, 0.2f); +static constant float3 HG_MATERIAL = float3(1.25f, 0.34f, 0.18f); +static constant float3 HG_FUNDCOL = float3(0.3f, 1.0f, 8.0f); +static constant float HG_LIGHTENING_FACTOR = 8.0f; + +static float hg_dihedral(float x) { + return cos(HG_PI / x); +} + +static float2 hg_rot2d(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 hg_rotateX(float3 p, float a) { + float c = cos(a); + float s = sin(a); + return float3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); +} + +static float3 hg_rotateY(float3 p, float a) { + float c = cos(a); + float s = sin(a); + return float3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); +} + +static HGLSMirrors hg_setupMirrors() { + float cp = hg_dihedral(HG_PQR.x); + float sp = sqrt(max(1.0f - cp * cp, 0.0f)); + float cq = hg_dihedral(HG_PQR.y); + float cr = hg_dihedral(HG_PQR.z); + + HGLSMirrors mirrors; + mirrors.A = float3(0.0f, 0.0f, 1.0f); + mirrors.B = float3(0.0f, sp, -cp); + mirrors.D = float3(1.0f, 0.0f, 0.0f); + + float r = 1.0f / cr; + float k = r * cq / sp; + float3 cen = float3(1.0f, k, 0.0f); + mirrors.C = float4(cen, r) / sqrt(dot(cen, cen) - r * r); + return mirrors; +} + +static float hg_distABCD(float3 p, HGLSMirrors mirrors) { + float dA = abs(dot(p, mirrors.A)); + float dB = abs(dot(p, mirrors.B)); + float dD = abs(dot(p, mirrors.D)); + float dC = abs(length(p - mirrors.C.xyz) - mirrors.C.w); + return min(dA, min(dB, min(dC, dD))); +} + +static bool hg_tryReflectPlane(thread float3 &p, float3 n, thread int &count) { + float k = dot(p, n); + if (k >= 0.0f) { + return true; + } + p -= 2.0f * k * n; + count += 1; + return false; +} + +static bool hg_tryReflectSphere(thread float3 &p, float4 sphere, thread int &count, thread float &orb) { + float3 q = p - sphere.xyz; + float d2 = dot(q, q); + if (d2 == 0.0f) { + return true; + } + float k = (sphere.w * sphere.w) / d2; + if (k < 1.0f) { + return true; + } + p = k * q + sphere.xyz; + count += 1; + orb *= k; + return false; +} + +static bool hg_iterateSpherePoint(thread float3 &p, thread int &count, thread float &orb, HGLSMirrors mirrors) { + for (int iter = 0; iter < HG_MAX_REFLECTIONS; ++iter) { + bool inA = hg_tryReflectPlane(p, mirrors.A, count); + bool inB = hg_tryReflectPlane(p, mirrors.B, count); + bool inC = hg_tryReflectSphere(p, mirrors.C, count, orb); + bool inD = hg_tryReflectPlane(p, mirrors.D, count); + p = normalize(p); + if (inA && inB && inC && inD) { + return true; + } + } + return false; +} + +static float3 hg_chooseColor(bool found, int count, float orb) { + float3 col; + if (found) { + if (count == 0) { + return HG_FUNDCOL; + } else if (count >= 180) { + col = HG_MATERIAL; + } else { + col = ((count & 1) == 0) ? HG_CHECKER1 : HG_CHECKER2; + } + } else { + col = HG_MATERIAL; + } + + float t = float(count) / float(HG_MAX_REFLECTIONS); + float orbMix = 1.0f - t * smoothstep(0.0f, 1.0f, log(max(orb, 1e-6f)) / 32.0f); + col = mix(HG_MATERIAL * HG_LIGHTENING_FACTOR, col, orbMix); + return col; +} + +static float hg_sdSphere(float3 p, float radius) { + return length(p) - radius; +} + +static float hg_sdPlane(float3 p) { + return p.y + 1.0f; +} + +static float3 hg_planeToSphere(float2 p) { + float pp = dot(p, p); + return float3(2.0f * p.x, pp - 1.0f, 2.0f * p.y) / (1.0f + pp); +} + +static float2 hg_map(float3 p) { + float3 q = p / HG_SCENE_SCALE; + float dSphere = hg_sdSphere(q, 1.0f) * HG_SCENE_SCALE; + float dPlane = hg_sdPlane(q) * HG_SCENE_SCALE; + return (dSphere < dPlane) ? float2(dSphere, 0.0f) : float2(dPlane, 1.0f); +} + +static float3 hg_getNormal(float3 p) { + const float2 e = float2(0.001f, 0.0f); + return normalize(float3( + hg_map(p + e.xyy).x - hg_map(p - e.xyy).x, + hg_map(p + e.yxy).x - hg_map(p - e.yxy).x, + hg_map(p + e.yyx).x - hg_map(p - e.yyx).x)); +} + +static float2 hg_raymarch(float3 ro, float3 rd, float maxDist) { + float t = HG_MIN_TRACE_DIST; + float2 h = float2(-1.0f, -1.0f); + for (int i = 0; i < HG_MAX_TRACE_STEPS; ++i) { + h = hg_map(ro + t * rd); + if (h.x < max(0.00035f, HG_PRECISION * max(t, 1.0f))) { + return float2(t, h.y); + } + if (t > maxDist) { + break; + } + t += max(h.x, 0.0006f); + } + return float2(-1.0f, -1.0f); +} + +static float hg_calcOcclusion(float3 p, float3 n) { + float occ = 0.0f; + float sca = 1.0f; + for (int i = 0; i < 5; ++i) { + float h = 0.01f + 0.12f * float(i) / 4.0f; + float d = hg_map(p + h * n).x; + occ += (h - d) * sca; + sca *= 0.75f; + } + return clamp(1.0f - occ, 0.0f, 1.0f); +} + +static float3 hg_getColor(float3 ro, float3 rd, float3 pos, float3 nor, float3 lp, float3 basecol) { + float3 ld = lp - pos; + float lDist = max(length(ld), 0.001f); + ld /= lDist; + float ao = hg_calcOcclusion(pos, nor); + float diff = clamp(dot(nor, ld), 0.0f, 1.0f); + float atten = 2.0f / (1.0f + lDist * lDist * 0.08f); + float spec = pow(max(dot(reflect(-ld, nor), -rd), 0.0f), 32.0f); + float fres = clamp(1.0f + dot(rd, nor), 0.0f, 1.0f); + + float3 col = basecol * (0.18f + 0.82f * diff); + col += basecol * float3(1.0f, 0.8f, 0.3f) * spec * 0.75f; + col += basecol * 0.35f * pow(fres, 5.0f); + col *= ao * atten; + col += basecol * clamp(0.8f + 0.2f * nor.y, 0.0f, 1.0f) * 0.25f; + return col; +} + +static float hg_boxHit(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 hyperbolicGroupLimitSetFragment( + HGLSVertexOut in [[stage_in]], + constant HyperbolicGroupLimitSetUniforms &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 rd = normalize(in.worldPos - camWorld); + + bool insideBox = all(abs(eye) < (HG_BOX_HALF - 1e-3f)); + float3 entryNormal; + float entryT = hg_boxHit(eye, rd, HG_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 / HG_BOX_HALF.xy + + entryPoint.yz * faceNormal.x / HG_BOX_HALF.yz + + entryPoint.zx * faceNormal.y / HG_BOX_HALF.zx; + float edgeCoord = max(abs(faceCoords.x), abs(faceCoords.y)); + float edgeGlow = smoothstep(0.84f, 0.985f, edgeCoord); + float borderMask = 1.0f - smoothstep(0.92f, 1.02f, edgeCoord); + + float3 start = entryPoint + rd * 0.0015f; + float3 exitNormal; + float exitT = hg_boxHit(start, rd, HG_BOX_HALF, exitNormal, false); + if (exitT < 0.0f) { + exitT = 4.0f; + } + + HGLSMirrors mirrors = hg_setupMirrors(); + float rotationX = 0.74f * HG_PI; + float rotationY = uniforms.time * 0.12f; + + float2 res = hg_raymarch(start, rd, exitT); + float3 glassBase = mix(float3(0.02f, 0.03f, 0.05f), float3(0.08f, 0.16f, 0.22f), 1.0f - borderMask); + glassBase += edgeGlow * float3(0.10f, 0.20f, 0.28f); + + if (res.x < 0.0f) { + float falloff = exp(-0.35f * exitT * exitT); + float3 missCol = mix(glassBase, float3(0.0f), 1.0f - falloff); + return float4(clamp(missCol, 0.0f, 1.0f), 1.0f); + } + + float t = res.x; + float id = res.y; + float3 pos = start + t * rd; + float3 q = pos / HG_SCENE_SCALE; + float3 nor = hg_getNormal(pos); + float3 lp = float3(0.55f, 0.92f, -0.35f); + lp.xz = hg_rot2d(lp.xz, uniforms.time * 0.18f); + + int count = 0; + float orb = 1.0f; + bool found; + float edist; + + if (id < 0.5f) { + float3 samplePoint = hg_rotateY(hg_rotateX(normalize(q), rotationX), rotationY); + found = hg_iterateSpherePoint(samplePoint, count, orb, mirrors); + edist = hg_distABCD(samplePoint, mirrors); + } else { + float3 samplePoint = hg_planeToSphere(q.xz); + samplePoint = hg_rotateY(hg_rotateX(samplePoint, rotationX), rotationY); + found = hg_iterateSpherePoint(samplePoint, count, orb, mirrors); + edist = hg_distABCD(samplePoint, mirrors); + } + + float3 basecol = hg_chooseColor(found, count, orb); + float3 col = hg_getColor(start, rd, pos, nor, lp, basecol); + col = mix(col, float3(0.0f), (1.0f - smoothstep(0.0f, 0.007f, edist)) * 0.85f); + col = mix(col, glassBase * 0.9f, 1.0f - exp(-0.05f * t * t)); + + float fresnel = pow(clamp(1.0f - abs(dot(faceNormal, rd)), 0.0f, 1.0f), 3.0f); + col += fresnel * float3(0.08f, 0.12f, 0.18f); + col = mix(col, glassBase + edgeGlow * float3(0.12f, 0.20f, 0.28f), 0.12f); + col = mix(col, 1.0f - exp(-col), 0.35f); + col = sqrt(max(col, 0.0f)); + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetTypes.swift b/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetTypes.swift new file mode 100644 index 0000000..2b50546 --- /dev/null +++ b/vr-dive/Demos/HyperbolicGroupLimitSet/HyperbolicGroupLimitSetTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct HyperbolicGroupLimitSetUniforms in +/// HyperbolicGroupLimitSetShaders.metal. +struct HyperbolicGroupLimitSetUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeRenderer.swift b/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeRenderer.swift new file mode 100644 index 0000000..24fb416 --- /dev/null +++ b/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// InterferenceCascadeCubeRenderer.swift +// +// Original implementation for a cube-portal interference field scene. +// Visual inspiration requested from ShaderToy scSGD1: +// https://www.shadertoy.com/view/scSGD1 +// This implementation is original and does not reuse source code from the +// reference shader. + +final class InterferenceCascadeCubeRenderer: VisualPatternController { + let identifier: VisualPatternKind = .interferenceCascadeCube + 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.75 + private let objectCenter = SIMD3(0.0, -0.05, -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 = InterferenceCascadeCubeRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try InterferenceCascadeCubeRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = InterferenceCascadeCubeRenderer.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 = InterferenceCascadeCubeUniforms( + 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 InterferenceCascadeCubeRenderer { + 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: "interferenceCascadeCubeVertex") + desc.fragmentFunction = library.makeFunction(name: "interferenceCascadeCubeFragment") + 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/InterferenceCascadeCube/InterferenceCascadeCubeShaders.metal b/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeShaders.metal new file mode 100644 index 0000000..9227b1f --- /dev/null +++ b/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeShaders.metal @@ -0,0 +1,156 @@ +// InterferenceCascadeCubeShaders.metal +// +// Original cube-portal interference field scene. +// Visual inspiration requested from ShaderToy scSGD1: +// https://www.shadertoy.com/view/scSGD1 +// This implementation is original and does not reuse source code from the +// reference shader. + +#include +using namespace metal; + +#define ICC_STEPS 42 +#define ICC_MAX_DIST 22.0f +#define ICC_SCENE_SCALE 11.0f + +struct InterferenceCascadeCubeUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct InterferenceCascadeCubeVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex InterferenceCascadeCubeVertexOut interferenceCascadeCubeVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant InterferenceCascadeCubeUniforms &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; + + InterferenceCascadeCubeVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 icc_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 float4 icc_palette(float x) { + return 0.5f + 0.5f * cos(x + float4(0.0f, 1.0f, 2.0f, 0.0f)); +} + +static float icc_logRepeatMetric(float x, float time, thread float &radiusA, thread float &radiusB) { + float safeX = max(x, 1e-3f); + float layer = floor(time - log(safeX) / 0.48f); + radiusA = pow(0.69f, layer - time + 0.4f); + radiusB = radiusA * 0.67f; + return layer; +} + +static float icc_map(float3 p, float time) { + p.z -= 2.7f; + p.xz = icc_rot(p.xz, 0.3f); + p.zy = icc_rot(p.zy, sin(time * 0.25f) * 0.3f + 0.9f); + p.xy = icc_rot(p.xy, time * 0.25f); + p.x = abs(p.x) - 1.0f; + + float radiusA; + float radiusB; + icc_logRepeatMetric(abs(p.x) + 0.05f, time, radiusA, radiusB); + + float wave = smoothstep(0.88f, 1.0f, cos(length(p.xy + p.z) * 2.1f - time)); + float lobe = abs(length(p - float3(radiusA, 0.0f, 0.0f)) - radiusA * 0.23f); + float lobe2 = abs(length(p - float3(radiusB, 0.0f, 0.0f)) - radiusA * 0.15f); + + float3 q = p; + q.yx = icc_rot(q.yx, atan2(q.y, q.x)); + q.zx = icc_rot(q.zx, atan2(max(q.z, 0.0f), q.x)); + float corridor = length(q.xy) - (0.13f + 0.05f * wave); + float spine = abs(q.z) - (0.08f + 0.02f * wave); + + return min(min(lobe, lobe2), min(corridor, spine)); +} + +static bool icc_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 interferenceCascadeCubeFragment( + InterferenceCascadeCubeVertexOut in [[stage_in]], + constant InterferenceCascadeCubeUniforms &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 (!icc_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float time = uniforms.time * uniforms.travelSpeed; + float3 ro = (roLocal + rdLocal * max(tEntry, 0.0f)) * ICC_SCENE_SCALE; + float3 rd = normalize(rdLocal); + + float t = 0.0f; + float4 accum = float4(0.0f); + for (int i = 0; i < ICC_STEPS; ++i) { + float3 p = ro + rd * t; + float radiusA; + float radiusB; + icc_logRepeatMetric(abs(p.x) + 0.05f, time, radiusA, radiusB); + + float d = icc_map(p, time); + float lxy = max(length(p.xy), 1e-3f); + float pulse = smoothstep(0.9f, 1.0f, cos(lxy * 1.8f + p.z * 0.65f - time)); + float4 glow = (0.008f + 0.018f * pulse) + * (1.0f + cos(lxy * 2.0f + float(i) * 0.2f + float4(0.0f, 1.0f, 2.0f, 0.0f))) + / (0.45f + 1.25f * lxy); + float density = exp(-15.0f * abs(d)); + float4 tint = icc_palette(lxy + p.z * 0.4f + time * 0.5f); + accum += tint * glow * density * 1.7f; + + t += clamp(d * 0.82f + 0.035f, 0.05f, 0.9f); + if (t > ICC_MAX_DIST) { break; } + } + + float3 color = tanh(accum.xyz * 0.95f); + color = pow(color, float3(0.88f)); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeTypes.swift b/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeTypes.swift new file mode 100644 index 0000000..5a90785 --- /dev/null +++ b/vr-dive/Demos/InterferenceCascadeCube/InterferenceCascadeCubeTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct InterferenceCascadeCubeUniforms in +/// InterferenceCascadeCubeShaders.metal. +struct InterferenceCascadeCubeUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/KuKo/KuKoRenderer.swift b/vr-dive/Demos/KuKo/KuKoRenderer.swift new file mode 100644 index 0000000..3f2fbe2 --- /dev/null +++ b/vr-dive/Demos/KuKo/KuKoRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// KuKoRenderer.swift +// 3D cube-container adaptation of "KuKo Day 384" (ShaderToy NXfGDl). +// Original: https://www.shadertoy.com/view/NXfGDl +// DDA algorithm by @xor: https://www.shadertoy.com/view/XctSz8 + +final class KuKoRenderer: VisualPatternController { + let identifier: VisualPatternKind = .kuKo + 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 = KuKoRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0)) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try KuKoRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = KuKoRenderer.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 = KuKoUniforms( + 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 KuKoRenderer { + 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: "kuKoVertex") + desc.fragmentFunction = library.makeFunction(name: "kuKoFragment") + 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/KuKo/KuKoShaders.metal b/vr-dive/Demos/KuKo/KuKoShaders.metal new file mode 100644 index 0000000..c967071 --- /dev/null +++ b/vr-dive/Demos/KuKo/KuKoShaders.metal @@ -0,0 +1,266 @@ +// KuKoShaders.metal +// 3D visionOS adaptation of "KuKo Day 384" (ShaderToy NXfGDl). +// +// Original GLSL source: +// https://www.shadertoy.com/view/NXfGDl +// DDA algorithm from @xor: https://www.shadertoy.com/view/XctSz8 +// Ported to Metal / visionOS cube-container by the vr-dive project. +// +// Technique: DDA voxel traversal with volumetric Henyey-Greenstein lighting. +// The cube is a window into an infinite voxel field that drifts over time. + +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Shared types (must match KuKoTypes.swift) +// --------------------------------------------------------------------------- + +struct KuKoUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct KuKoVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Scene functions — direct translations of GLSL originals +// --------------------------------------------------------------------------- + +// Boolean solid test (returns true = solid, stop marching) +static bool kukMap(float3 p) { + return dot(sin(p * 0.13f), cos(p.yzx * 0.4384f)) + p.y * 0.0561f > 0.9f; +} + +// Scalar density field (for volumetric light accumulation) +static float kukMap2(float3 p) { + return dot(sin(p * 0.13f), cos(p.yzx * 0.4384f)) + p.y * 0.061f; +} + +// Scalar hash for palette index +static float kukHash(float3 p) { + return fract(sin(dot(p, float3(127.1f, 311.7f, 411.7f))) * 43758.5453f); +} + +// Vector hash — used for sub-voxel jitter (@Shane technique) +static float3 kukHash33(float3 p) { + float n = sin(dot(p, float3(7.0f, 157.0f, 113.0f))); + return fract(float3(2097152.0f, 262144.0f, 32768.0f) * n); +} + +// Axis-aligned box intersection; returns (tNear, tFar) +static float2 kukBoxIntersect(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)); +} + +// --------------------------------------------------------------------------- +// Vertex +// --------------------------------------------------------------------------- + +vertex KuKoVertexOut kuKoVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant KuKoUniforms &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; + + KuKoVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment — DDA voxel traversal + Henyey-Greenstein volumetric lighting +// --------------------------------------------------------------------------- + +fragment float4 kuKoFragment( + KuKoVertexOut in [[stage_in]], + constant KuKoUniforms &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 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 + bool insideBox = all(abs(eye) < halfExt - 1.0e-3f); + float2 tBox = kukBoxIntersect(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 in local cube space → scene space. + // sceneScale maps the cube ±1 to ±8 scene units. + // The time-based offset mirrors the original flying camera: + // ro = vec3(-2, 2, iTime * SPEED) + const float sceneScale = 8.0f; + const float SPEED = 7.0f; + float t = uniforms.time; + // Flip z: ShaderToy/GLSL content runs in +Z; cube local -Z (viewDir.z ≈ -1 + // when looking at the front face) must map to scene +Z. + float3 ro_entry = (eye + viewDir * (tStart + 0.001f)); + float3 ro = float3(ro_entry.x, ro_entry.y, -ro_entry.z) * sceneScale + + float3(-2.0f, 2.0f, t * SPEED); + + // Ray direction stays normalized — DDA step sizes handle the rest. + float3 rd = float3(viewDir.x, viewDir.y, -viewDir.z); + + // Prevent division-by-zero in DDA step size computation + // (mirrors GLSL: rd += vec3(rd.x==0, rd.y==0, rd.z==0) * 1e-5) + if (rd.x == 0.0f) rd.x = 1.0e-5f; + if (rd.y == 0.0f) rd.y = 1.0e-5f; + if (rd.z == 0.0f) rd.z = 1.0e-5f; + + // Colour palette (only indices 0–2 are reached; full array retained for fidelity) + float3 colArr[7] = { + float3(0.100f, 0.100f, 0.100f), + float3(0.659f, 0.000f, 0.353f), + float3(0.518f, 0.043f, 0.322f), + float3(0.349f, 0.027f, 0.000f), + float3(0.383f, 0.782f, 1.000f), + float3(0.542f, 0.549f, 0.625f), + float3(0.277f, 0.133f, 0.137f) + }; + + // --------------------------------------------------------------------------- + // DDA initialisation + // --------------------------------------------------------------------------- + float3 axisDir = sign(rd); + float3 stepDir = 1.0f / abs(rd); // t-length per unit step per axis + float3 vox = floor(ro); + // Initial crossing depths: how far to first boundary on each axis + float3 xyCrossing = ((vox - ro) * axisDir + 0.6f) * stepDir; + + // State — initialised to safe defaults + float3 axis = float3(0.0f); + float3 p = float3(0.0f); + float3 vox2 = float3(0.0f); // saved voxel before step (original uses vox2 but only saves it) + + float accum = 0.0f; + float voxDt = 0.0f; + float att = 0.0f; + float steps = 0.0f; + float transmittance = 1.0f; + float stepL = 0.0f; + float henyey = 0.0f; + float newXyCros = 0.0f; + float edge = 0.0f; + float3 lightAcc = float3(0.0f); + + // Light position in scene space (mirrors original: L = vec3(-2, 2, iTime*SPEED + 15)) + float3 L = float3(-2.0f, 2.0f, t * SPEED + 15.0f); + + // --------------------------------------------------------------------------- + // DDA traversal — translated directly from GLSL mainImage loop + // --------------------------------------------------------------------------- + for (int i = 1; i < 80; i++) { + // Hit test: if current voxel is solid, stop + if (kukMap(vox)) break; + + // Sub-voxel jitter to suppress stepping artefacts (@Shane) + xyCrossing += kukHash33(vox + ro) * stepDir * 0.005f; + + // Attenuation from light distance + voxDt = length((vox + 0.5f) - L) * 0.2f; + att = 1.0f / (80.0f + voxDt * voxDt); + accum += att; + steps += 1.0f; + + // Axis selection: which boundary is crossed next? + axis = xyCrossing.x < xyCrossing.z + ? (xyCrossing.x < xyCrossing.y ? float3(1,0,0) : float3(0,1,0)) + : (xyCrossing.z < xyCrossing.y ? float3(0,0,1) : float3(0,1,0)); + + // World position at boundary crossing + p = ro + dot(xyCrossing, axis) * rd; + + // Henyey-Greenstein phase function (g = 0.45) + float angleL = dot(rd, normalize(p - L)); + float g = 0.45f; + henyey = (1.0f - g) / (4.0f * M_PI_F + * pow(1.0f + g*g - 2.0f*g*cos(angleL), 2.5f)); + + // Advance voxel + vox2 = vox; + vox += axis * axisDir; + newXyCros = dot(xyCrossing, axis); + xyCrossing += axis * stepDir; + + // Volumetric shadow / light accumulation + stepL = dot(xyCrossing, axis) - newXyCros * 0.07f; + transmittance *= exp(-0.000712f * stepL); + lightAcc += max(kukMap2(p), 0.0f) * henyey * transmittance * stepL * att; + + // Edge detection via crossing depths + float dx = xyCrossing.x; + float dy = xyCrossing.y + 0.5f; + float dz = xyCrossing.z - 0.2f; + float d2 = min(dy, max(dz, dy)); + edge = 1.0f - smoothstep(-5.0f, 0.4f, d2 - dz); + } + + // --------------------------------------------------------------------------- + // Surface shading — same as original post-loop code + // --------------------------------------------------------------------------- + float3 nor = axisDir * axis; + float3 hit = p + rd * dot(xyCrossing - stepDir, axis); + float NoV = clamp(dot(nor, -hit), 0.0f, 1.0f); + float rim = pow(1.0f - NoV, 3.0f) * 10.0f; + + // Grid lines per unit voxel + const float SCALE = 1.0f; + float3 grid = max(float3(0.7f) - 2.0f * abs(fract(p + 0.5f)) * SCALE, 0.0f); + float3 newGrid = mix(float3(0.061f), float3(0.0f), grid.x + grid.y + grid.z); + + // Random palette index: hash * 2 + 0.4 → float in [0.4, 2.4) → int 0/1/2 + float rand = kukHash(floor(vox)) * 2.0f + 0.4f; + int ci = max(0, min(6, int(rand))); + + float3 col = colArr[ci] * 2.0f + + float3(0.902f, 0.059f, 0.678f) * edge + * (1.5f + sin(t * 2.0f) * 0.5f + 0.5f) * rim; + + col = lightAcc * col; + col -= newGrid; + + float fog = steps / 80.0f; + col = mix(col, float3(0.2f, 0.24f, 0.3f), fog); + + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/KuKo/KuKoTypes.swift b/vr-dive/Demos/KuKo/KuKoTypes.swift new file mode 100644 index 0000000..67675fa --- /dev/null +++ b/vr-dive/Demos/KuKo/KuKoTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct KuKoUniforms in KuKoShaders.metal. +struct KuKoUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/LaceTunnel/LaceTunnelRenderer.swift b/vr-dive/Demos/LaceTunnel/LaceTunnelRenderer.swift new file mode 100644 index 0000000..80e4948 --- /dev/null +++ b/vr-dive/Demos/LaceTunnel/LaceTunnelRenderer.swift @@ -0,0 +1,173 @@ +import Metal +import simd + +// LaceTunnelRenderer.swift +// +// Renders a lace-pattern tunnel inside a 2 m × 2 m × 2 m cube container. +// Ported from ShaderToy "4sGSzc" (Aiekick, 2015), CC BY-NC-SA 3.0. +// https://www.shadertoy.com/view/4sGSzc +// +// Architecture follows GlassBoxRenderer: a cube mesh acts as the container; +// the fragment shader (LaceTunnelShaders.metal) does all ray-marching work. +// Local half-extents = (1, 1, 1), world scale = 1.0 → 2 m × 2 m × 2 m cube. + +final class LaceTunnelRenderer: VisualPatternController { + let identifier: VisualPatternKind = .laceTunnel + 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 + + // World-space scale and placement. + // boxScale 1.0 → cube half-extents are 1 m → 2 m × 2 m × 2 m. + 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) + + // Cube mesh in local ±1 space, enlarged 2 % so the rasterised mesh fully + // covers all visible pixels before the fragment shader takes over. + let geo = LaceTunnelRenderer.makeBox( + device: device, localHalfExtents: SIMD3(1, 1, 1) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try LaceTunnelRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = LaceTunnelRenderer.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) + + // Vertex buffers: [0] vertices, [1] uniforms, [2] VP matrices. + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + + var uniforms = LaceTunnelUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + boxScale: boxScale, + _pad: 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) + } + } + + // Fragment buffers: [0] uniforms, [1] view-to-world transforms. + 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) + } +} + +// MARK: - Geometry & Pipeline factory +extension LaceTunnelRenderer { + + /// Build a box mesh with the given half-extents in local space. + /// 6 faces × 4 verts = 24 vertices, 6 × 2 triangles = 36 indices. + /// Normals point outward; setCullMode(.none) makes both sides visible. + 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]), // +Z + ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]), // -Z + ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]), // +X + ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]), // -X + ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]), // +Y + ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]), // -Y + ] + 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 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: "laceTunnelVertex") + desc.fragmentFunction = library.makeFunction(name: "laceTunnelFragment") + 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 // reverse-Z: near=1, far=0 + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} diff --git a/vr-dive/Demos/LaceTunnel/LaceTunnelShaders.metal b/vr-dive/Demos/LaceTunnel/LaceTunnelShaders.metal new file mode 100644 index 0000000..612e869 --- /dev/null +++ b/vr-dive/Demos/LaceTunnel/LaceTunnelShaders.metal @@ -0,0 +1,209 @@ +// LaceTunnelShaders.metal +// Adapted from "Lace Tunnel" by Stephane Cuillerdier (Aiekick), 2015. +// https://www.shadertoy.com/view/4sGSzc +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Renders a lace-pattern tunnel inside a 2 m × 2 m × 2 m cube container. +// Container ray-marching: from outside the box, the ray enters at the cube +// surface; when the camera is inside the box the ray starts at the camera. +// The tunnel SDF extends indefinitely in tunnel space; the cube limits the +// visible portion. Pattern does NOT clip at the container boundary. + +#include +using namespace metal; + +// ─── Uniforms ───────────────────────────────────────────────────────────────── +// Layout must match LaceTunnelUniforms in LaceTunnelTypes.swift. +struct LaceTunnelUniforms { + float time; + uint viewCount; + float boxScale; // world-space scale: worldPos = localPos * boxScale + center + float _pad; + float4 objectCenter; // xyz = world-space box centre +}; + +struct LaceMeshVertex { + float3 position; + float3 normal; +}; + +struct LaceTunnelVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ─── Vertex shader ──────────────────────────────────────────────────────────── +vertex LaceTunnelVertexOut laceTunnelVertex( + ushort amplificationID [[amplification_id]], + const device LaceMeshVertex *vertices [[buffer(0)]], + constant LaceTunnelUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + LaceMeshVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz; + + LaceTunnelVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ─── Tunnel SDF helpers ─────────────────────────────────────────────────────── +// Source: Stephane Cuillerdier (Aiekick) 2015, https://www.shadertoy.com/view/4sGSzc +// Functions prefixed lt_ to avoid name collisions with other shaders in the lib. + +// Tunnel centreline at depth t (slow helix, radius 2). +static float2 lt_path(float t) { + return float2(cos(t * 0.2f), sin(t * 0.2f)) * 2.0f; +} + +// Diagonal scale matrices (column-major, same layout as GLSL mat3). +// mx = diag(1,7,7) my = diag(7,1,7) mz = diag(7,7,1) +constant float3x3 lt_mx = float3x3(float3(1,0,0), float3(0,7,0), float3(0,0,7)); +constant float3x3 lt_my = float3x3(float3(7,0,0), float3(0,1,0), float3(0,0,7)); +constant float3x3 lt_mz = float3x3(float3(7,0,0), float3(0,7,0), float3(0,0,1)); + +// One-tweet cellular distance (Shane's technique, via Aiekick). +static float lt_func(float3 p) { + p = fract(p / 68.6f) - 0.5f; + return min(min(abs(p.x), abs(p.y)), abs(p.z)) + 0.1f; +} + +// Warped cellular pattern. +// mz*mx*my = diag(49,49,49) = 49·I, so the matrix chain simplifies to 49. +static float3 lt_effect(float3 p) { + p *= 49.0f * sin(p.zxy); // p *= (mz*mx*my)*sin(p.zxy), with product = 49·I + return float3(min(min(lt_func(p * lt_mx), lt_func(p * lt_my)), lt_func(p * lt_mz)) / 0.6f); +} + +// Surface displacement amount + colour (w = dist, xyz = black-line colour). +static float4 lt_displacement(float3 p) { + float3 col = 1.0f - lt_effect(p * 0.8f); + col = clamp(col, -0.5f, 1.0f); + float dist = dot(col, float3(0.023f)); + col = step(col, float3(0.82f)); // black line on shape + return float4(dist, col); +} + +// Main SDF: returns (signed distance, rgb colour). +// Tunnel axis is +Z, tube radius ≈ 4, centreline follows lt_path(z). +static float4 lt_map(float3 p) { + p.xy -= lt_path(p.z); + float4 disp = lt_displacement(sin(p.zxy * 2.0f) * 0.8f); + p += sin(p.zxy * 0.5f) * 1.5f; + float l = length(p.xy) - 4.0f; + return float4(max(-l + 0.09f, l) - disp.x, disp.yzw); +} + +// Finite-difference surface normal. +static float3 lt_nor(float3 pos) { + const float eps = 0.1f; + float2 e = float2(eps, 0.0f); + return normalize(float3( + lt_map(pos + e.xyy).x - lt_map(pos - e.xyy).x, + lt_map(pos + e.yxy).x - lt_map(pos - e.yxy).x, + lt_map(pos + e.yyx).x - lt_map(pos - e.yyx).x)); +} + +// Lighting: point light at lightPos. Returns (rgb colour, light distance). +static float4 lt_light(float3 ro, float3 rd, float d, float3 lightPos) { + float3 p = ro + rd * d; + float3 n = lt_nor(p); + float3 lightDir = lightPos - p; + float lightLen = length(lightDir); + lightDir /= lightLen; + float amb = 0.6f; + float diff = clamp(dot(n, lightDir), 0.0f, 1.0f); + float3 brdf = amb * float3(0.2f, 0.5f, 0.3f); // material colour + brdf += diff * 0.6f; + brdf = mix(brdf, lt_map(p).yzw, 0.5f); // blend lighting & lace pattern + return float4(brdf, lightLen); +} + +// ─── Box intersection ───────────────────────────────────────────────────────── +// ro and rd in object/local space; halfExt are the box half-extents. +// Returns (tNear, tFar); if tNear > tFar the ray misses. +static float2 lt_boxIntersect(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 shader ────────────────────────────────────────────────────────── +fragment float4 laceTunnelFragment( + LaceTunnelVertexOut in [[stage_in]], + constant LaceTunnelUniforms &uniforms [[buffer(0)]], + constant float4x4 *v2wMats [[buffer(1)]]) +{ + uint vi = min(in.viewIndex, uniforms.viewCount - 1u); + + // Camera world position from view-to-world transform (column 3). + float4x4 v2w = v2wMats[vi]; + float3 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + + // Convert to object (local) space: local = (world − centre) / scale. + float3 center = uniforms.objectCenter.xyz; + float sc = uniforms.boxScale; + float3 eye = (camWorld - center) / sc; // camera in object space + float3 hit = (in.worldPos - center) / sc; // cube surface in object space + float3 rd = normalize(hit - eye); // ray direction (unit) + + // Box half-extents in object space are exactly ±1 (unit-cube mesh × 1). + const float3 halfBox = float3(1.0f); + bool insideBox = all(abs(eye) < halfBox - 1e-3f); + float2 tBox = lt_boxIntersect(eye, rd, halfBox); + + 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(); + + // ── Map to tunnel space ────────────────────────────────────────────────── + // Scale object space up so the 2 m cube shows a meaningful slice of the + // tunnel (tube radius 4, path offset ±2). Advance z by time so the + // tunnel scrolls toward the viewer (matches original: camera.z = time). + const float SCALE = 5.0f; + const float SPEED = 1.0f; // tunnel units / second (same rate as original) + + // roT: ray origin in tunnel space (at cube entry point or at camera). + // rdT == rd: direction is unchanged under uniform scale + translation. + // maxd: maximum march distance in tunnel space. + float3 roT = (eye + rd * tStart) * SCALE + float3(0.0f, 0.0f, uniforms.time * SPEED); + float maxd = min(40.0f, (tEnd - tStart) * SCALE); + + // ── Ray march ──────────────────────────────────────────────────────────── + // Translated directly from Aiekick's loop. + // Special break condition for thin surfaces: st=0 on first iter yields + // NaN in the log term → 0 < NaN = false → safe to skip on iter 0. + float st = 0.0f; + float d = 0.0f; + float ao = 0.0f; + for (int i = 0; i < 150; i++) { + if (st < 0.025f * log(d * d / st / 1e5f) || d > maxd) break; + float4 m = lt_map(roT + rd * d); + st = m.x; + d += st * 0.6f; + ao += 1.0f; + } + + // ── Shade ──────────────────────────────────────────────────────────────── + if (d >= maxd) { + discard_fragment(); // ray exited box without hitting → let scene show through + } + + // Point light at the ray origin (camera-like light, same as original). + float4 li = lt_light(roT, rd, d, roT); + float3 col = li.xyz / (li.w * 0.2f); // cheap distance attenuation + col = mix(float3(1.0f - ao / 100.0f), col, 0.5f); // low-cost AO + col = mix(col, float3(0.0f), 1.0f - exp(-0.003f * d * d)); // depth fog + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/LaceTunnel/LaceTunnelTypes.swift b/vr-dive/Demos/LaceTunnel/LaceTunnelTypes.swift new file mode 100644 index 0000000..34df44a --- /dev/null +++ b/vr-dive/Demos/LaceTunnel/LaceTunnelTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct LaceTunnelUniforms in LaceTunnelShaders.metal. +struct LaceTunnelUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float // uniform world-space scale applied to the local ±1 half-extents + var _pad: Float // padding to keep float4 aligned + var objectCenter: SIMD4 // xyz = world position of box centre +} diff --git a/vr-dive/Demos/Lanterns/LanternsRenderer.swift b/vr-dive/Demos/Lanterns/LanternsRenderer.swift new file mode 100644 index 0000000..594def5 --- /dev/null +++ b/vr-dive/Demos/Lanterns/LanternsRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// LanternsRenderer.swift +// Original Lanterns implementation for vr-dive. +// Request referenced https://www.shadertoy.com/view/4sB3D1, but that source has +// restrictive terms prohibiting reuse/adaptation. This renderer is an original +// implementation that targets the same 3D cube-portal requirements. + +final class LanternsRenderer: VisualPatternController { + let identifier: VisualPatternKind = .lanterns + 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, -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 = LanternsRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try LanternsRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = LanternsRenderer.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 = LanternsUniforms( + 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 LanternsRenderer { + 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: "lanternsVertex") + desc.fragmentFunction = library.makeFunction(name: "lanternsFragment") + 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/Lanterns/LanternsShaders.metal b/vr-dive/Demos/Lanterns/LanternsShaders.metal new file mode 100644 index 0000000..3c914bc --- /dev/null +++ b/vr-dive/Demos/Lanterns/LanternsShaders.metal @@ -0,0 +1,303 @@ +// LanternsShaders.metal +// Original Lanterns implementation for vr-dive. +// Request referenced https://www.shadertoy.com/view/4sB3D1, but that source has +// restrictive terms prohibiting reuse/adaptation. This shader is an original +// lantern-field implementation designed for the same 3D cube-portal behavior. + +#include +using namespace metal; + +struct LanternsUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct LanternsVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct LanternInfo { + float3 center; + float groundY; + float radius; + float stemRadius; + float3 glowColor; +}; + +struct LanternSceneSample { + float dist; + float material; + float glow; + float3 glowColor; + float2 cell; +}; + +static constant float3 LAN_BOX_HALF = float3(1.0f); +static constant float LAN_SCENE_SCALE = 3.12f; +static constant float3 LAN_SCENE_OFFSET = float3(0.0f, 0.0f, -1.0f); +static constant float LAN_MAX_DISTANCE = 28.0f; +static constant int LAN_MAX_STEPS = 144; +static constant float LAN_EPSILON = 0.0012f; + +vertex LanternsVertexOut lanternsVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant LanternsUniforms &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; + + LanternsVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3 lanHash33(float2 p) { + float n = dot(p, float2(41.0f, 289.0f)); + return fract(sin(float3(n, n + 1.0f, n + 2.0f)) * float3(43758.5453f, 22578.1459f, 19642.3490f)); +} + +static float lanBoxHit(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 float lanSdRoundBox(float3 p, float3 b, float r) { + float3 q = abs(p) - b; + return length(max(q, 0.0f)) + min(max(q.x, max(q.y, q.z)), 0.0f) - r; +} + +static float lanSdSphere(float3 p, float r) { + return length(p) - r; +} + +static float lanSdCapsule(float3 p, float3 a, float3 b, float r) { + float3 pa = p - a; + float3 ba = b - a; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0f, 1.0f); + return length(pa - ba * h) - r; +} + +static float lanSdTorus(float3 p, float2 t) { + float2 q = float2(length(p.xz) - t.x, p.y); + return length(q) - t.y; +} + +static LanternInfo lanMakeLantern(float2 cell, float time) { + float3 seed = lanHash33(cell); + float2 offset = (seed.xy - 0.5f) * 0.38f; + float headHeight = -0.10f + 0.55f * seed.z; + float bob = 0.10f * sin(time * (0.7f + 0.5f * seed.x) + seed.y * 6.28318f + dot(cell, float2(0.7f, 1.1f))); + + LanternInfo info; + info.radius = 0.10f + 0.045f * seed.z; + info.center = float3(cell.x + offset.x, headHeight + bob, cell.y + offset.y); + info.groundY = -1.05f; + info.stemRadius = mix(0.018f, 0.032f, seed.x); + info.glowColor = mix(float3(1.0f, 0.50f, 0.16f), float3(1.0f, 0.78f, 0.35f), seed.y); + return info; +} + +static LanternSceneSample lanMap(float3 p, float time) { + LanternSceneSample sample; + sample.dist = 1.0e6f; + sample.material = 0.0f; + sample.glow = 0.0f; + sample.glowColor = float3(0.0f); + sample.cell = float2(0.0f); + + float2 baseCell = floor(p.xz); + for (int oy = -1; oy <= 1; ++oy) { + for (int ox = -1; ox <= 1; ++ox) { + float2 cell = baseCell + float2(float(ox), float(oy)); + LanternInfo info = lanMakeLantern(cell, time); + float3 q = p - info.center; + + float head = lanSdSphere(q, info.radius); + float headCore = lanSdSphere(q, info.radius * 0.68f); + float3 stemBase = float3(info.center.x, info.groundY, info.center.z); + float3 stemTop = info.center - float3(0.0f, info.radius * 0.92f, 0.0f); + float stem = lanSdCapsule(p, stemBase, stemTop, info.stemRadius); + float collar = lanSdTorus(q - float3(0.0f, info.radius * 0.10f, 0.0f), float2(info.radius * 0.16f, info.radius * 0.055f)); + float lantern = min(min(head, stem), collar); + + if (lantern < sample.dist) { + sample.dist = lantern; + sample.material = head < min(stem, collar) ? 0.0f : (stem < collar ? 1.0f : 2.0f); + sample.glowColor = info.glowColor; + sample.cell = cell; + } + + float inner = max(headCore, -head); + float localGlow = 0.05f / (0.025f + inner * inner); + sample.glow += localGlow; + sample.glowColor += info.glowColor * localGlow; + } + } + + // Keep a weak ground reference well below the main lantern cluster so it + // doesn't mask most front-facing rays before they reach the lanterns. + float floorPlane = p.y + 1.05f; + if (floorPlane < sample.dist) { + sample.dist = floorPlane; + sample.material = 3.0f; + sample.cell = floor(p.xz); + } + + sample.glowColor /= max(sample.glow, 1.0e-4f); + return sample; +} + +static float lanMapDistance(float3 p, float time) { + return lanMap(p, time).dist; +} + +static float3 lanNormal(float3 p, float time) { + float2 e = float2(0.0015f, -0.0015f); + return normalize( + e.xyy * lanMapDistance(p + e.xyy, time) + + e.yyx * lanMapDistance(p + e.yyx, time) + + e.yxy * lanMapDistance(p + e.yxy, time) + + e.xxx * lanMapDistance(p + e.xxx, time)); +} + +static float lanAmbientOcclusion(float3 p, float3 n, float time) { + float occlusion = 0.0f; + float weight = 1.0f; + for (int i = 0; i < 5; ++i) { + float h = 0.04f + 0.12f * float(i); + float d = lanMapDistance(p + n * h, time); + occlusion += (h - d) * weight; + weight *= 0.6f; + } + return clamp(1.0f - 1.8f * occlusion, 0.0f, 1.0f); +} + +static float3 lanBackground(float3 rd) { + float t = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float3 low = float3(0.01f, 0.014f, 0.022f); + float3 high = float3(0.05f, 0.07f, 0.11f); + float stars = pow(max(0.0f, sin(rd.x * 91.0f) * sin(rd.y * 117.0f) * sin(rd.z * 83.0f)), 18.0f); + return mix(low, high, t) + stars * 0.18f; +} + +fragment float4 lanternsFragment( + LanternsVertexOut in [[stage_in]], + constant LanternsUniforms &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 : lanBoxHit(eye, rd, LAN_BOX_HALF, faceNormal, true); + if (!insideBox && entryT < 0.0f) { + discard_fragment(); + } + + float time = uniforms.time; + float3 marchOrigin = insideBox ? (eye + rd * 0.002f) : (eye + rd * (entryT + 0.002f)); + + float travel = 0.0f; + LanternSceneSample hitSample; + hitSample.dist = 0.0f; + float3 pos = marchOrigin * LAN_SCENE_SCALE + LAN_SCENE_OFFSET; + bool hit = false; + for (int i = 0; i < LAN_MAX_STEPS; ++i) { + float3 worldPoint = marchOrigin + rd * travel; + pos = worldPoint * LAN_SCENE_SCALE + LAN_SCENE_OFFSET; + hitSample = lanMap(pos, time); + float worldDist = hitSample.dist / LAN_SCENE_SCALE; + if (worldDist < LAN_EPSILON) { + hit = true; + break; + } + if (travel > LAN_MAX_DISTANCE) { + break; + } + travel += worldDist * 0.72f; + } + + float3 color = lanBackground(rd); + float fogGlow = min(hitSample.glow, 0.35f); + color += hitSample.glowColor * fogGlow * 0.035f; + + if (hit) { + float3 n = lanNormal(pos, time); + float ao = lanAmbientOcclusion(pos, n, time); + float3 view = -rd; + float3 warmLightDir = normalize(float3(-0.5f, 0.9f, -0.3f)); + float diffuse = max(dot(n, warmLightDir), 0.0f); + float rim = pow(1.0f - max(dot(n, view), 0.0f), 3.0f); + + float3 baseColor; + if (hitSample.material < 0.5f) { + baseColor = hitSample.glowColor * 0.45f + float3(0.35f, 0.12f, 0.06f); + } else if (hitSample.material < 1.5f) { + baseColor = float3(0.18f, 0.12f, 0.06f); + } else if (hitSample.material < 2.5f) { + baseColor = float3(0.12f, 0.08f, 0.04f); + } else { + float tile = 0.5f + 0.5f * sin(dot(floor(hitSample.cell), float2(1.0f, 7.0f))); + baseColor = mix(float3(0.06f, 0.05f, 0.04f), float3(0.12f, 0.09f, 0.06f), tile); + } + + float3 localEmission = hitSample.glowColor * min(hitSample.glow, 1.5f) * (hitSample.material < 2.5f ? 0.95f : 0.04f); + float3 lighting = float3(0.05f, 0.06f, 0.08f) * ao; + lighting += diffuse * float3(0.9f, 0.75f, 0.55f) * ao; + lighting += rim * hitSample.glowColor * 0.25f; + + color = baseColor * lighting + localEmission; + color *= exp(-0.012f * travel * travel); + } + + float3 surfacePos = insideBox ? eye : (eye + rd * entryT); + float3 absSurface = abs(surfacePos); + float3 surfaceNormal = absSurface.x > absSurface.y && absSurface.x > absSurface.z + ? float3(sign(surfacePos.x), 0.0f, 0.0f) + : (absSurface.y > absSurface.z + ? float3(0.0f, sign(surfacePos.y), 0.0f) + : float3(0.0f, 0.0f, sign(surfacePos.z))); + float fresnel = pow(1.0f - max(dot(-rd, surfaceNormal), 0.0f), 2.0f); + color += float3(0.12f, 0.07f, 0.03f) * fresnel * 0.06f; + + color = pow(clamp(color, 0.0f, 1.0f), float3(0.44f)); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Lanterns/LanternsTypes.swift b/vr-dive/Demos/Lanterns/LanternsTypes.swift new file mode 100644 index 0000000..fa8bdca --- /dev/null +++ b/vr-dive/Demos/Lanterns/LanternsTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct LanternsUniforms in LanternsShaders.metal. +struct LanternsUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerRenderer.swift b/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerRenderer.swift new file mode 100644 index 0000000..a67a78b --- /dev/null +++ b/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// LogSphericalKIFSZoomerRenderer.swift +// Cube-container adaptation of ShaderToy "Log Spherical KIFS Zoomer" (ctcGRf). + +final class LogSphericalKIFSZoomerRenderer: VisualPatternController { + let identifier: VisualPatternKind = .logSphericalKIFSZoomer + 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, -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 = LogSphericalKIFSZoomerRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try LogSphericalKIFSZoomerRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = LogSphericalKIFSZoomerRenderer.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 = LogSphericalKIFSZoomerUniforms( + 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 LogSphericalKIFSZoomerRenderer { + 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: "logSphericalKIFSZoomerVertex") + desc.fragmentFunction = library.makeFunction(name: "logSphericalKIFSZoomerFragment") + 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/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerShaders.metal b/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerShaders.metal new file mode 100644 index 0000000..a8568be --- /dev/null +++ b/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerShaders.metal @@ -0,0 +1,344 @@ +// LogSphericalKIFSZoomerShaders.metal +// "Log Spherical KIFS Zoomer" — cube-container adaptation of ShaderToy ctcGRf. +// Source: https://www.shadertoy.com/view/ctcGRf +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Adaptation notes: +// - The original shader ray-marches an unbounded log-spherical repeated KIFS scene +// from a synthetic camera placed far from the origin. +// - This version reconstructs a real per-eye ray from a visible 2 m cube, starts +// from the cube surface when the viewer is outside, keeps the actual SDF scene +// in a larger decoupled scene space, and supports viewing from inside the cube. + +#include +using namespace metal; + +struct LogSphericalKIFSZoomerUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct LogSphericalKIFSZoomerVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float LKZ_GAMMA = 2.2f; +static constant int LKZ_MAX_STEPS = 90; +static constant float LKZ_MAX_DIST = 100.0f; +static constant float LKZ_MIN_DIST = 10.0f; +static constant float LKZ_GLOW_INT = 1.0f; +static constant float LKZ_PP_ACES = 1.0f; +static constant float LKZ_PP_CONT = 0.5f; +static constant float LKZ_PP_VIGN = 1.3f; +static constant float LKZ_AO_OCC = 0.5f; +static constant float LKZ_AO_SCA = 0.3f; +static constant float LKZ_PI = 3.14159265f; +static constant float LKZ_DENS = 0.9f; +static constant float LKZ_SCENE_SCALE = 26.0f; +static constant float3 LKZ_BOX_HALF = float3(1.0f); +static constant float3 LKZ_AMB_COL = float3(0.03f, 0.05f, 0.1f) * 5.5f; +static constant float3 LKZ_SUN_COL = float3(1.0f, 0.7f, 0.4f) * 1.2f; +static constant float3 LKZ_SKY_COL = float3(0.3f, 0.5f, 1.0f) * 0.04f; +static constant float LKZ_SPEC_EXP = 4.0f; + +vertex LogSphericalKIFSZoomerVertexOut logSphericalKIFSZoomerVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant LogSphericalKIFSZoomerUniforms &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; + + LogSphericalKIFSZoomerVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float lkzSmooth(float a, float b, float t) { + return smoothstep(a, b, t); +} + +static float lkzSin3(float x) { + float s = sin(x); + return s * s * s; +} + +static float2 lkzRot2D(float2 p, float a) { + float c = cos(a); + float s = sin(a); + return c * p + s * float2(p.y, -p.x); +} + +static float3 lkzRot(float3 p, float3 r) { + p.xz = lkzRot2D(p.xz, r.y); + p.yx = lkzRot2D(p.yx, r.z); + p.zy = lkzRot2D(p.zy, r.x); + return p; +} + +static float2 lkzBoxIntersect(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 lkzSdKMC(float3 p, int iters, float3 fTra, float3 fRot, float4 para) { + int i = 0; + float x1 = 0.0f; + float y1 = 0.0f; + float r = dot(p, p); + + for (i = 0; i < iters && r < 1.0e6f; ++i) { + if (i > 0) { + p -= fTra; + p = lkzRot(p, fRot); + } + + p = abs(p); + if (p.x - p.y < 0.0f) { x1 = p.y; p.y = p.x; p.x = x1; } + if (p.x - p.z < 0.0f) { x1 = p.z; p.z = p.x; p.x = x1; } + if (p.y - p.z < 0.0f) { y1 = p.z; p.z = p.y; p.y = y1; } + + p.z -= 0.5f * para.x * (para.y - 1.0f) / para.y; + p.z = -abs(p.z); + p.z += 0.5f * para.x * (para.y - 1.0f) / para.y; + + p.x = para.y * p.x - para.z * (para.y - 1.0f); + p.y = para.y * p.y - para.w * (para.y - 1.0f); + p.z = para.y * p.z; + r = dot(p, p); + } + + return length(p) * pow(para.y, float(-i)); +} + +static float3 lkzHsv2rgbSmooth(float3 c) { + float3 rgb = clamp(abs(fmod(c.x * 6.0f + float3(0.0f, 4.0f, 2.0f), 6.0f) - 3.0f) - 1.0f, 0.0f, 1.0f); + rgb = rgb * rgb * (3.0f - 2.0f * rgb); + return c.z * mix(float3(1.0f), rgb, c.y); +} + +static float3 lkzPalette(int index, float time) { + switch (index) { + case 0: + return float3(1.0f, 1.0f, 1.0f); + case 1: + return float3(1.0f, 0.8f, 0.6f); + case 2: + return float3(0.6f, 0.8f, 1.0f); + case 3: + return lkzHsv2rgbSmooth(float3(fract(time / 21.0f), 0.65f, 0.8f)); + default: + return float3(0.0f); + } +} + +static float2 lkzSDF(float3 p, float depth, float time) { + float d = LKZ_MAX_DIST; + float col = 0.0f; + + p = abs(lkzRot(p, float3(10.5f - depth))); + + float sphere = length(p - float3(1.8f + sin(time / 3.0f + depth) * 0.6f, 0.0f, 0.0f)) - 0.1f; + col = mix(col, 1.7f, step(sphere, d)); + d = min(sphere, d); + + float torus = length(float2(length(p.yz) - 1.2f, p.x)) - 0.01f; + col = mix(col, 1.3f, step(torus, d)); + d = min(torus, d); + + float menger = lkzSdKMC( + p * 2.9f, + 8, + float3(sin(time / 53.0f)) * 0.4f, + float3(lkzSin3(time / 64.0f) * LKZ_PI), + float4(2.0f, 3.5f, 4.5f, 5.5f)) / 2.9f; + col = mix(col, floor(fmod(length(p) * 1.5f, 4.0f)) + 0.5f, step(menger, d)); + d = min(menger, d); + + return float2(d, col); +} + +static float2 lkzMap(float3 p, float time) { + float r = max(length(p), 1.0e-5f); + float theta = acos(clamp(p.z / r, -1.0f, 1.0f)); + float phi = atan2(p.y, p.x); + p = float3(log(r), theta, phi); + + float t = time / 10.0f; + p.x -= t; + float scale = floor(p.x * LKZ_DENS) + t * LKZ_DENS; + p.x = fmod(p.x, 1.0f / LKZ_DENS); + if (p.x < 0.0f) { + p.x += 1.0f / LKZ_DENS; + } + + float erho = exp(p.x); + float sintheta = sin(p.y); + p = float3( + erho * sintheta * cos(p.z), + erho * sintheta * sin(p.z), + erho * cos(p.y)); + + float2 sdf = lkzSDF(p, scale, time); + sdf.x *= exp(scale / LKZ_DENS); + return sdf; +} + +static float3 lkzNormal(float3 p, float depth, float time) { + float h = max(depth * 0.0025f, 0.002f); + const float2 k = float2(1.0f, -1.0f); + return normalize( + k.xyy * lkzMap(p + k.xyy * h, time).x + + k.yyx * lkzMap(p + k.yyx * h, time).x + + k.yxy * lkzMap(p + k.yxy * h, time).x + + k.xxx * lkzMap(p + k.xxx * h, time).x); +} + +static float lkzCalcAO(float3 p, float3 n, float time) { + float occ = LKZ_AO_OCC; + float sca = LKZ_AO_SCA; + for (int i = 0; i < 5; ++i) { + float h = 0.001f + 0.150f * float(i) / 4.0f; + float d = lkzMap(p + h * n, time).x; + occ += (h - d) * sca; + sca *= 0.95f; + } + return lkzSmooth(0.0f, 1.0f, 1.0f - 1.5f * occ); +} + +static float3 lkzShade(float3 col, float mat, float3 p, float3 n, float3 rd, float3 lp, float time) { + float3 lidi = normalize(lp - p); + float amoc = lkzCalcAO(p, n, time); + float diff = max(dot(n, lidi), 0.0f); + float spec = pow(diff, max(1.0f, LKZ_SPEC_EXP * mat)); + float refl = pow(max(0.0f, dot(lidi, reflect(rd, n))), max(1.0f, LKZ_SPEC_EXP * 3.0f * mat)); + return col * (amoc * LKZ_AMB_COL + (1.0f - mat) * diff * LKZ_SUN_COL + mat * (spec + refl) * LKZ_SUN_COL); +} + +static float4 lkzPostProcess(float3 col, float2 uv) { + float3 aces = (col * (2.51f * col + 0.03f)) / (col * (2.43f * col + 0.59f) + 0.14f); + col = mix(col, aces, LKZ_PP_ACES); + col = mix(col, smoothstep(float3(0.0f), float3(1.0f), col), LKZ_PP_CONT); + col *= lkzSmooth(LKZ_PP_VIGN, -LKZ_PP_VIGN / 5.0f, dot(uv, uv)); + col = pow(max(col, 0.0f), float3(1.0f / LKZ_GAMMA)); + return float4(col, 1.0f); +} + +struct LKZMarchResult { + float distance; + float steps; + float material; +}; + +static LKZMarchResult lkzRayMarch(float3 ro, float3 rd, float time) { + float col = 0.0f; + float dO = mix(LKZ_MIN_DIST, LKZ_MAX_DIST / 2.0f, lkzSmooth(0.9f, 1.0f, sin(time / 24.0f) * 0.5f + 0.5f)); + int steps = 0; + + for (int i = 0; i < LKZ_MAX_STEPS; ++i) { + steps = i; + float3 p = ro + rd * dO; + float2 dS = lkzMap(p, time); + col = dS.y; + dO += min(dS.x, length(p) / 12.0f); + if (dO > LKZ_MAX_DIST || dS.x < max(dO * 0.0025f, 0.002f)) { + break; + } + } + + LKZMarchResult result; + result.distance = (steps == 0) ? LKZ_MIN_DIST : dO; + result.steps = float(steps); + result.material = col; + return result; +} + +static float2 lkzFaceUV(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); +} + +fragment float4 logSphericalKIFSZoomerFragment( + LogSphericalKIFSZoomerVertexOut in [[stage_in]], + constant LogSphericalKIFSZoomerUniforms &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 rdLocal = normalize(surfacePos - eye); + + bool insideCube = all(abs(eye) < LKZ_BOX_HALF - 1.0e-3f); + float2 tCube = lkzBoxIntersect(eye, rdLocal, LKZ_BOX_HALF); + if (!insideCube && tCube.x > tCube.y) { + discard_fragment(); + } + + float tStart = insideCube ? 0.0f : max(tCube.x, 0.0f); + float3 localOrigin = eye + rdLocal * (tStart + 0.001f); + + float3 ro = localOrigin * LKZ_SCENE_SCALE; + float3 rd = normalize(rdLocal); + + float3 bg = LKZ_SKY_COL; + float3 col = bg; + float3 p = float3(0.0f); + LKZMarchResult rmd = lkzRayMarch(ro, rd, uniforms.time); + + if (rmd.distance <= LKZ_MIN_DIST) { + col = lkzPalette(int(floor(rmd.material)), uniforms.time) / 8.0f; + } else if (rmd.distance < LKZ_MAX_DIST) { + p = ro + rd * rmd.distance; + float3 n = lkzNormal(p, rmd.distance, uniforms.time); + float shine = fract(rmd.material); + col = lkzPalette(int(floor(abs(rmd.material))), uniforms.time); + col = lkzShade(col, shine, p, n, rd, float3(0.0f), uniforms.time); + } + + float disFac = lkzSmooth(0.0f, 1.0f, pow(rmd.distance / LKZ_MAX_DIST, 2.0f)); + col = mix(col, bg, disFac); + col += pow(rmd.steps / float(LKZ_MAX_STEPS), 2.5f) * normalize(LKZ_AMB_COL) + * (LKZ_GLOW_INT + ((rmd.distance < LKZ_MAX_DIST) + ? 3.0f * lkzSmooth(0.995f, 1.0f, sin(uniforms.time / 2.0f - length(p) / 20.0f)) + : 0.0f)); + + float2 faceUV = lkzFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + float4 outCol = lkzPostProcess(col, faceUV * 0.95f); + outCol.rgb *= vignette; + return float4(clamp(outCol.rgb, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerTypes.swift b/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerTypes.swift new file mode 100644 index 0000000..47ffcaa --- /dev/null +++ b/vr-dive/Demos/LogSphericalKIFSZoomer/LogSphericalKIFSZoomerTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct LogSphericalKIFSZoomerUniforms in +/// LogSphericalKIFSZoomerShaders.metal. +struct LogSphericalKIFSZoomerUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Magnetar/MagnetarRenderer.swift b/vr-dive/Demos/Magnetar/MagnetarRenderer.swift new file mode 100644 index 0000000..b83801e --- /dev/null +++ b/vr-dive/Demos/Magnetar/MagnetarRenderer.swift @@ -0,0 +1,167 @@ +import Metal +import simd + +// MagnetarRenderer.swift +// +// Source reference: +// https://www.shadertoy.com/view/NclXWn +// "Magnetar" — reworked from https://www.shadertoy.com/view/XfK3zV +// License: see original ShaderToy page + +final class MagnetarRenderer: VisualPatternController { + let identifier: VisualPatternKind = .magnetar + 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 → cubeScale = 1.0 maps [-1,1] local to [-1m,1m] world) + 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 = MagnetarRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try MagnetarRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = MagnetarRenderer.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 = MagnetarUniforms( + 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 MagnetarRenderer { + 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: "magnetarVertex") + desc.fragmentFunction = library.makeFunction(name: "magnetarFragment") + 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/Magnetar/MagnetarShaders.metal b/vr-dive/Demos/Magnetar/MagnetarShaders.metal new file mode 100644 index 0000000..eb078dc --- /dev/null +++ b/vr-dive/Demos/Magnetar/MagnetarShaders.metal @@ -0,0 +1,172 @@ +// MagnetarShaders.metal +// +// Source reference: +// https://www.shadertoy.com/view/NclXWn +// "Magnetar" — reworked from https://www.shadertoy.com/view/XfK3zV +// License: see original ShaderToy page +// +// Adapted for vr-dive: renders inside a view-independent 2 metre cube container. +// The original ShaderToy camera orbit is replaced by visionOS head-pose ray marching. + +#include +using namespace metal; + +#define MAG_STEPS 100 +#define MAG_FAR 40.0f +#define MAG_NEAR 1e-3f + +struct MagnetarUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct MagnetarVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Vertex shader +// --------------------------------------------------------------------------- +vertex MagnetarVertexOut magnetarVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant MagnetarUniforms &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; + + MagnetarVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Box intersection (slab method, handles inside-box case) +// --------------------------------------------------------------------------- +static bool mag_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); +} + +// --------------------------------------------------------------------------- +// Oscillate helper (GLSL: O(x,a,b) = (cos(x*6.2832)*.5+.5)*(a-b)+b) +// --------------------------------------------------------------------------- +static float mag_oscillate(float x, float a, float b) { + return (cos(x * 6.2832f) * 0.5f + 0.5f) * (a - b) + b; +} + +// --------------------------------------------------------------------------- +// SDF (port of map() from ShaderToy NclXWn) +// --------------------------------------------------------------------------- +static float mag_map(float3 p, float T) +{ + float density = mag_oscillate(T / 8.0f, 10.0f, 30.0f); + float b = (dot(p, p) - 1.0f) / density; // coordinate transform + + // Inside or right at the unit sphere b≤0: the coordinate transform p/=b + // would divide by zero or flip the sign of all coordinates, producing + // completely wrong tube distances (white blob / red arcs artifact). + // Return a large step so the march passes cleanly through the sphere. + if (b <= 0.05f) return 1.0f; + + p /= b; + float x = T + round(p.x - T); // tile and move along x + p.x -= x; + + float s = min(b, length(p.xz - round(p.xz)) + 0.05f); // tubes + return s; +} + +// --------------------------------------------------------------------------- +// Fragment shader +// --------------------------------------------------------------------------- +fragment float4 magnetarFragment( + MagnetarVertexOut in [[stage_in]], + constant MagnetarUniforms &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 (!mag_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tNear, tFar)) { + discard_fragment(); + } + + // Start march from the entry point (or camera if inside the box) + float tStart = max(tNear, 0.0f); + + // SCENE_SCALE = 6: local [-1,1] → scene [-6,6]. The unit sphere (r=1) sits at + // the origin; interesting field-line content is at r=1.5–5. With scale=1.2 + // (the old value) the box barely reached r=1.2 — almost entirely inside the + // sphere where b=(|p|²-1)/density≤0 → p/=b blows up → white blob artifact. + const float SCENE_SCALE = 6.0f; + + float3 ro = roLocal * SCENE_SCALE; + float3 rd = normalize(rdLocal); + + float T = uniforms.time * 0.25f; // matches original T = iTime/4. + + // Accumulated colour (emission-only volumetric, matches original c += min(.001/s, s)) + float3 c = float3(0.0f); + float d = tStart * SCENE_SCALE; + float travelMax = tFar * SCENE_SCALE; + + for (int i = 0; i < MAG_STEPS; ++i) { + if (d >= travelMax) break; + float3 p = ro + rd * d; + float s = mag_map(p, T); + + if (s < MAG_NEAR) break; + + c += min(0.001f / s, s); + + // Adaptive step: coord-transform shrinks steps near sphere surface. + // b is re-evaluated at the current march position (same as original B macro). + float density = mag_oscillate(T / 8.0f, 10.0f, 30.0f); + float b = (dot(p, p) - 1.0f) / density; + d += s * clamp(b, 0.3f, 2.0f); + } + + // Colour tint — original: c *= vec3(.7,.8,.9) / min(sqrt(length(uv)), 1.) + // uv is 2D pixel offset from screen centre; analogous in VR is the angular + // distance of the ray from the viewing axis (length of rd.xy). + float radialUV = length(rd.xy); // 0 at straight-ahead, ~0.7 at 45° + c *= float3(0.7f, 0.8f, 0.9f) / max(sqrt(radialUV), 0.15f); + + // tanh(c^3) for contrast + HDR limiting + float3 col = tanh(c * c * c); + + return float4(col, 1.0f); +} diff --git a/vr-dive/Demos/Magnetar/MagnetarTypes.swift b/vr-dive/Demos/Magnetar/MagnetarTypes.swift new file mode 100644 index 0000000..65cf65f --- /dev/null +++ b/vr-dive/Demos/Magnetar/MagnetarTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct MagnetarUniforms in +/// MagnetarShaders.metal. +struct MagnetarUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldRenderer.swift b/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldRenderer.swift new file mode 100644 index 0000000..10639f3 --- /dev/null +++ b/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldRenderer.swift @@ -0,0 +1,171 @@ +import Metal +import simd + +// MagneticLinesThatDrawInGoldRenderer.swift +// "Magnetic lines that draw in gold" — cube-portal adaptation of Shadertoy "3cBXRy" +// Original: https://www.shadertoy.com/view/3cBXRy + +final class MagneticLinesThatDrawInGoldRenderer: VisualPatternController { + let identifier: VisualPatternKind = .magneticLinesThatDrawInGold + 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 = MagneticLinesThatDrawInGoldRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try MagneticLinesThatDrawInGoldRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = MagneticLinesThatDrawInGoldRenderer.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 = MagneticLinesThatDrawInGoldUniforms( + 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 MagneticLinesThatDrawInGoldRenderer { + 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: "magneticLinesThatDrawInGoldVertex") + desc.fragmentFunction = library.makeFunction(name: "magneticLinesThatDrawInGoldFragment") + 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)! + } +} \ No newline at end of file diff --git a/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldShaders.metal b/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldShaders.metal new file mode 100644 index 0000000..b8d673e --- /dev/null +++ b/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldShaders.metal @@ -0,0 +1,242 @@ +// MagneticLinesThatDrawInGoldShaders.metal +// "Magnetic lines that draw in gold" — cube-portal adaptation of Shadertoy "3cBXRy" +// Original: https://www.shadertoy.com/view/3cBXRy +// Source note from the original shader: "2025-3-14 / apollo". +// +// Metal adaptation notes: +// - The original shader is a screen-space ray marcher with mouse-driven +// orientation tweaks. This version replaces that camera with the real per-eye +// world ray from a 2 m cube portal. +// - Outside the cube, marching starts at the visible container surface. +// - Inside the cube, marching starts at the eye. +// - The authored magnetic-gold line structure remains unbounded in scene space, +// so it is not clipped by the cube bounds. + +#include +using namespace metal; + +struct MagneticLinesThatDrawInGoldUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct MagneticLinesThatDrawInGoldVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float MLDG_PI = 3.1415926535f; +static constant float MLDG_MAX_TRACE_DISTANCE = 15.0f; +static constant int MLDG_MAX_TRACE_STEPS = 256; +static constant int MLDG_SHADOW_STEPS = 80; +static constant int MLDG_AO_STEPS = 24; +static constant float MLDG_SCENE_SCALE = 0.23f; +static constant float3 MLDG_SCENE_OFFSET = float3(0.0f, 0.02f, 0.0f); +static constant float3 MLDG_BOX_HALF = float3(1.0f); + +vertex MagneticLinesThatDrawInGoldVertexOut magneticLinesThatDrawInGoldVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant MagneticLinesThatDrawInGoldUniforms &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; + + MagneticLinesThatDrawInGoldVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 mldgRotate(float2 p, float a) { + float s = sin(a); + float c = cos(a); + return float2(c * p.x - s * p.y, s * p.x + c * p.y); +} + +static float2 mldgHash2(float n) { + return fract(sin(float2(n, n + 1.0f)) * float2(43758.5453123f, 22578.1459123f)); +} + +static float mldgApollo(float3 p) { + float j = 1.0f; + float k; + float rxy; + float r0 = length(p) - 0.6f; + + for (int i = 0; i < 9; ++i) { + p = 2.0f * clamp(p, -2.0f, 2.0f) - p; + k = max(1.0f, 0.70968f / dot(p, p)); + p *= k; + j = j * k + 0.05f; + } + + rxy = length(p.xy); + return max(r0, max(rxy - 0.92784f, abs(rxy * p.z) / max(length(p), 1.0e-5f)) / j - 1.0e-4f); +} + +static float mldgMap(float3 p, float time) { + p.xy = mldgRotate(p.xy, 2.0f); + p.xz = mldgRotate(p.xz, MLDG_PI / 3.0f + 9.8f); + p.yz = mldgRotate(p.yz, 0.516f + 8.4f); + p.xy = mldgRotate(p.xy, time + 2.0f); + return mldgApollo(p); +} + +static float mldgCalcShadow(float3 ro, float3 rd, float k, float time) { + float res = 1.0f; + float t = 0.01f; + for (int i = 0; i < MLDG_SHADOW_STEPS; ++i) { + float3 pos = ro + t * rd; + float h = mldgMap(pos, time); + res = min(res, k * max(h, 0.0f) / t); + if (res < 1.0e-4f || pos.y > 10.0f) { + break; + } + t += clamp(h, 0.01f, 5.0f); + } + return res; +} + +static float mldgCalcOcclusion(float3 pos, float3 nor, float ra, float time) { + float occ = 0.0f; + for (int i = 0; i < MLDG_AO_STEPS; ++i) { + float fi = float(i); + float h = 0.01f + 4.0f * pow(fi / float(MLDG_AO_STEPS - 1), 2.0f); + float2 an = mldgHash2(ra + fi * 13.1f) * float2(3.14159f, 6.2831f); + float3 dir = float3(sin(an.x) * sin(an.y), sin(an.x) * cos(an.y), cos(an.x)); + dir *= sign(dot(dir, nor)); + occ += clamp(5.0f * mldgMap(pos + h * dir, time) / h, -1.0f, 1.0f); + } + return clamp(occ / float(MLDG_AO_STEPS), 0.0f, 1.0f); +} + +static float mldgTrace(float3 ro, float3 rd, float time, thread float &hitDistance) { + float t = 0.0f; + float d = 1.0f; + for (int i = 0; i < MLDG_MAX_TRACE_STEPS && t < MLDG_MAX_TRACE_DISTANCE; ++i) { + float3 p = ro + rd * t; + d = mldgMap(p, time); + if (d < 3.0e-4f) { + hitDistance = t; + return d; + } + t += d * 0.29f; + } + + hitDistance = t; + return d; +} + +static float3 mldgNormal(float3 p, float d, float time) { + float3 e = float3(0.0f, 1.0e-5f, 0.0f); + return normalize(float3( + mldgMap(p + e.yxx, time), + mldgMap(p + e, time), + mldgMap(p + e.xxy, time)) - d); +} + +static float3 mldgBackground(float3 rd) { + float t = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float3 low = float3(0.01f, 0.007f, 0.003f); + float3 high = float3(0.08f, 0.05f, 0.02f); + float horizon = pow(1.0f - abs(rd.z), 3.0f); + return mix(low, high, t) + horizon * float3(0.08f, 0.05f, 0.02f); +} + +static float mldgBoxHit(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 magneticLinesThatDrawInGoldFragment( + MagneticLinesThatDrawInGoldVertexOut in [[stage_in]], + constant MagneticLinesThatDrawInGoldUniforms &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 : mldgBoxHit(eye, rd, MLDG_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; + float3 ro = marchOrigin * MLDG_SCENE_SCALE + MLDG_SCENE_OFFSET; + + float hitT; + float d = mldgTrace(ro, rd, time, hitT); + float3 color = mldgBackground(rd); + + if (d < 3.0e-4f && hitT < MLDG_MAX_TRACE_DISTANCE) { + float3 p = ro + rd * hitT; + float3 s = normalize(float3(-1.0f, 2.0f, -3.0f)); + float shd = mldgCalcShadow(p - rd * max(d, 1.0e-4f), s, 100.0f, time); + float ao = mldgCalcOcclusion(p - rd * max(d, 1.0e-4f), s, 1.0f, time); + float3 n = mldgNormal(p, d, time); + float f = 0.5f + 0.5f * dot(n, s); + float g = max(dot(n, s), 0.0f); + float c = 1.0f + pow(f, 200.0f) - f * 0.3f; + + float3 gold = g * c; + gold = mix(gold * float3(3.0f, 2.0f, 1.0f), float3(0.5f), 1.0f - 1.0f / max(1.0f, hitT * hitT * 0.01f)); + gold *= shd; + gold *= min(0.2f * exp(-29.0f * p.z), 1.5f); + gold *= mix(0.55f, 1.0f, ao); + gold = mix(gold, float3(0.5f), smoothstep(5.0f, 15.0f, hitT)); + color = tanh(gold); + } + + 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.0f); + color += float3(0.08f, 0.05f, 0.02f) * fresnel * 0.06f; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldTypes.swift b/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldTypes.swift new file mode 100644 index 0000000..e04a845 --- /dev/null +++ b/vr-dive/Demos/MagneticLinesThatDrawInGold/MagneticLinesThatDrawInGoldTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct MagneticLinesThatDrawInGoldUniforms in +/// MagneticLinesThatDrawInGoldShaders.metal. +struct MagneticLinesThatDrawInGoldUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} \ No newline at end of file diff --git a/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixRenderer.swift b/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixRenderer.swift new file mode 100644 index 0000000..077d4a3 --- /dev/null +++ b/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// MarbleMovingRemixRenderer.swift +// Cube-container adaptation of ShaderToy "marble moving remix" (wstBRB). + +final class MarbleMovingRemixRenderer: VisualPatternController { + let identifier: VisualPatternKind = .marbleMovingRemix + 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 = MarbleMovingRemixRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try MarbleMovingRemixRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = MarbleMovingRemixRenderer.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 = MarbleMovingRemixUniforms( + 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 MarbleMovingRemixRenderer { + 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: "marbleMovingRemixVertex") + desc.fragmentFunction = library.makeFunction(name: "marbleMovingRemixFragment") + 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/MarbleMovingRemix/MarbleMovingRemixShaders.metal b/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixShaders.metal new file mode 100644 index 0000000..4566b9d --- /dev/null +++ b/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixShaders.metal @@ -0,0 +1,207 @@ +// MarbleMovingRemixShaders.metal +// "marble moving remix" — cube-container adaptation of ShaderToy wstBRB. +// Source: https://www.shadertoy.com/view/wstBRB +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// Original source note: fork of "Playing marble" by guil. +// +// Adaptation notes: +// - The original GLSL uses a synthetic screen camera orbiting a reflective +// marble sphere and samples iChannel0 for environment lookup. +// - This version reconstructs a real per-eye ray from the visible 2 m cube, +// starts at the cube surface when outside or at the eye when inside, and +// replaces the texture environment dependency with a procedural sky. + +#include +using namespace metal; + +struct MarbleMovingRemixUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct MarbleMovingRemixVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float MMR_ZOOM = 1.0f; +static constant float MMR_SPHERE_RADIUS = 2.0f; +static constant float3 MMR_BOX_HALF = float3(1.0f); + +vertex MarbleMovingRemixVertexOut marbleMovingRemixVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant MarbleMovingRemixUniforms &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; + + MarbleMovingRemixVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 mmrCSqr(float2 a) { + return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y); +} + +static float2 mmrRot(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 float2 mmrSphereIntersect(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 mmrMap(float3 p, float time) { + float res = 0.0f; + float3 c = p; + float growth = sin(time * 0.35432f) * 0.7f + 1.5f; + float shift = sin(time * 0.2443f) * 0.3f; + + for (int i = 0; i < 10; ++i) { + p = growth * abs(p) / max(dot(p, p), 1.0e-4f) - 0.4f + shift; + p.yz = mmrCSqr(p.yz); + p = p.zxy; + res += exp(-19.0f * abs(dot(p, c))); + } + return res * 0.5f; +} + +static float3 mmrEnvironment(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.28f, 0.42f, -0.86f))), 0.0f), 56.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x + dir.z) * 12.0f + time * 0.18f); + float3 sky = mix(float3(0.01f, 0.02f, 0.045f), float3(0.12f, 0.22f, 0.34f), skyMix); + sky += float3(0.06f, 0.16f, 0.28f) * horizon * shimmer * 0.35f; + sky += float3(1.0f, 0.95f, 0.88f) * sun; + return clamp(sky, 0.0f, 2.0f); +} + +static float2 mmrBoxIntersect(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 mmrFaceUV(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 mmrRaymarch(float3 ro, float3 rd, float2 tminmax, float time) { + float t = tminmax.x; + const float dt = 0.02f; + float3 col = float3(0.0f); + float c = 0.0f; + for (int i = 0; i < 64; ++i) { + t += dt * exp(-2.0f * c); + if (t > tminmax.y) { + break; + } + + c = mmrMap(ro + t * rd, time); + col = 0.99f * col + 0.08f * float3(c * c, c, c * c * c); + } + return col; +} + +fragment float4 marbleMovingRemixFragment( + MarbleMovingRemixVertexOut in [[stage_in]], + constant MarbleMovingRemixUniforms &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) < MMR_BOX_HALF - 1.0e-3f); + float2 tOuter = mmrBoxIntersect(eye, rd, MMR_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)) * (MMR_ZOOM * 4.0f); + + float orbitX = 0.1f * uniforms.time; + float orbitY = 0.12f * sin(uniforms.time * 0.13f); + ro.yz = mmrRot(ro.yz, orbitY); + ro.xz = mmrRot(ro.xz, orbitX); + + float3 marchDir = normalize(rd); + marchDir.yz = mmrRot(marchDir.yz, orbitY); + marchDir.xz = mmrRot(marchDir.xz, orbitX); + + float2 tmm = mmrSphereIntersect(ro, marchDir, float4(0.0f, 0.0f, 0.0f, MMR_SPHERE_RADIUS)); + float3 col; + + if (tmm.x < 0.0f && tmm.y < 0.0f) { + col = mmrEnvironment(marchDir, uniforms.time); + } else { + float tNear = max(tmm.x, 0.0f); + float tFar = max(tmm.y, tNear); + col = mmrRaymarch(ro, marchDir, float2(tNear, tFar), uniforms.time); + + float tSurface = (tmm.x > 0.0f) ? tmm.x : tmm.y; + float3 hitPos = ro + tSurface * marchDir; + float3 reflectedNormal = hitPos / MMR_SPHERE_RADIUS; + float3 reflected = reflect(marchDir, reflectedNormal); + float fre = pow(0.5f + clamp(dot(reflected, marchDir), 0.0f, 1.0f), 3.0f) * 1.3f; + col += mmrEnvironment(reflected, uniforms.time) * fre; + } + + col = 0.5f * log(1.0f + col); + col = clamp(col, 0.0f, 1.0f); + col.b = col.g * 3.0f; + + float2 faceUV = mmrFaceUV(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/MarbleMovingRemix/MarbleMovingRemixTypes.swift b/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixTypes.swift new file mode 100644 index 0000000..d0e96ef --- /dev/null +++ b/vr-dive/Demos/MarbleMovingRemix/MarbleMovingRemixTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct MarbleMovingRemixUniforms in +/// MarbleMovingRemixShaders.metal. +struct MarbleMovingRemixUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Metaball/MetaballRenderer.swift b/vr-dive/Demos/Metaball/MetaballRenderer.swift new file mode 100644 index 0000000..b22cf22 --- /dev/null +++ b/vr-dive/Demos/Metaball/MetaballRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +final class MetaballRenderer: VisualPatternController { + let identifier: VisualPatternKind = .metaball + 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 + + // Bounding sphere radius — slightly larger than the maximum metaball cluster extent. + // Balls wander up to 0.255 m + 0.065 m radius = 0.32 m, so 0.42 m gives comfortable margin. + private let boundingRadius: Float = 0.42 + + // World-space position: straight ahead, roughly eye-level. + private let objectCenter = SIMD3(0.0, 0.0, -1.0) + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + let geo = MetaballRenderer.makeUVSphere( + device: device, radius: boundingRadius, latSegments: 24, lonSegments: 48) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try MetaballRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = MetaballRenderer.makeDepthStencilState(device: device) + } + + func updateSimulation(_ context: PatternSimulationContext) {} + func resetToInitialState() {} + + 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 = MetaballUniforms( + time: context.time, + viewCount: UInt32(context.viewData.viewCount), + boundingRadius: boundingRadius, + 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) + } + } + + vpMatrices.withUnsafeBytes { + if let base = $0.baseAddress, $0.count > 0 { + encoder.setFragmentBytes(base, length: $0.count, index: 2) + } + } + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: indexCount, + indexType: .uint16, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } +} + +extension MetaballRenderer { + // UV sphere centred at the origin. latSegments × lonSegments quads. + // (latSegments+1) × (lonSegments+1) ≤ 25×49 = 1225 vertices → fits UInt16. + fileprivate static func makeUVSphere( + device: MTLDevice, + radius: Float, + latSegments: Int, + lonSegments: Int + ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) { + var vertices: [MeshVertex] = [] + var indices: [UInt16] = [] + 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 n = SIMD3(cos(phi) * sinTheta, cosTheta, sin(phi) * sinTheta) + vertices.append(MeshVertex(position: n * radius, normal: n)) + } + } + + let stride = UInt16(lonSegments + 1) + for lat in 0...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 descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "metaballVertex") + descriptor.fragmentFunction = library.makeFunction(name: "metaballFragment") + descriptor.colorAttachments[0].pixelFormat = .rgba16Float + descriptor.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 + descriptor.vertexDescriptor = vd + + descriptor.maxVertexAmplificationCount = max(maxViewCount, 1) + return try device.makeRenderPipelineState(descriptor: descriptor) + } + + fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState { + let desc = MTLDepthStencilDescriptor() + desc.depthCompareFunction = .greater // reverse-Z + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} diff --git a/vr-dive/Demos/Metaball/MetaballShaders.metal b/vr-dive/Demos/Metaball/MetaballShaders.metal new file mode 100644 index 0000000..26e4c81 --- /dev/null +++ b/vr-dive/Demos/Metaball/MetaballShaders.metal @@ -0,0 +1,221 @@ +#include +using namespace metal; + +// Must match MetaballUniforms in Swift. +struct MetaballUniforms { + float time; + uint viewCount; + float boundingRadius; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +// ── Vertex shader ───────────────────────────────────────────────────────────── +// Renders the bounding sphere; passes interpolated world position to fragment. + +struct MetaballVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex MetaballVertexOut metaballVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant MetaballUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + MeshVertex vtx = vertices[vertexID]; + float3 worldPos = vtx.position + uniforms.objectCenter.xyz; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + + MetaballVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ── Metaball SDF ────────────────────────────────────────────────────────────── +// Fixed ball count as a preprocessor constant so local arrays can use it. +#define BALL_COUNT 18 + +constant uint kBallCount = BALL_COUNT; +constant float kBallRadius = 0.065f; // world-space radius of each water drop +constant float kSmoothK = 0.085f; // merging smoothness (larger = blobs connect sooner) +constant float kMotionRange = 0.255f; // max distance from centre each ball wanders + +// Polynomial smooth-min. +float smin(float a, float b, float k) +{ + float h = saturate(0.5f + 0.5f * (b - a) / k); + return mix(b, a, h) - k * h * (1.0f - h); +} + +// Procedural per-ball position in dodecahedron-local space. +// Two sin components per axis give organic, non-repetitive motion. +float3 ballCenter(uint idx, float time) +{ + float i = float(idx); + float3 p = float3( + sin(time * 0.22f + i * 2.094f) * 0.70f + sin(time * 0.51f + i * 1.234f) * 0.30f, + sin(time * 0.18f + i * 1.745f) * 0.70f + sin(time * 0.37f + i * 2.456f) * 0.30f, + sin(time * 0.25f + i * 2.618f) * 0.70f + sin(time * 0.43f + i * 3.678f) * 0.30f + ); + return p * kMotionRange; +} + +// SDF using pre-computed centres (avoids recomputing ballCenter every march step). +float sdfFromCenters(float3 p, thread float3 *centers) +{ + float d = 1e9f; + for (uint i = 0u; i < kBallCount; i++) { + float di = length(p - centers[i]) - kBallRadius; + d = smin(d, di, kSmoothK); + } + return d; +} + +// Central-difference gradient (6 SDF evaluations → accurate normal). +float3 normalFromCenters(float3 p, thread float3 *centers) +{ + const float e = 0.003f; + return normalize(float3( + sdfFromCenters(p + float3(e, 0, 0), centers) - sdfFromCenters(p - float3(e, 0, 0), centers), + sdfFromCenters(p + float3(0, e, 0), centers) - sdfFromCenters(p - float3(0, e, 0), centers), + sdfFromCenters(p + float3(0, 0, e), centers) - sdfFromCenters(p - float3(0, 0, e), centers) + )); +} + +// ── Ray-sphere intersection ─────────────────────────────────────────────────── +// Returns (tEntry, tExit); miss when tEntry > tExit. +float2 raySphereHit(float3 ro, float3 rd, float r) +{ + float b = dot(ro, rd); + float c = dot(ro, ro) - r * r; + float h = b * b - c; + if (h < 0.0f) return float2(1.0f, -1.0f); + h = sqrt(h); + return float2(-b - h, -b + h); +} + +// ── Direction-based shading ─────────────────────────────────────────────────── +// Maps surface normal to a smooth HSV colour, plus a thin specular edge. +float3 directionShade(float3 normal, float3 viewDir) +{ + // Hue from azimuth of the normal (0–360° → 0–1). + float az = atan2(normal.z, normal.x); // -π … π + float hue = az / (2.0f * M_PI_F) + 0.5f; + + // Saturation peaks at the equator, fades at poles. + float el = asin(clamp(normal.y, -1.0f, 1.0f)); // -π/2 … π/2 + float sat = 0.80f + 0.20f * cos(el * 2.0f); + + // Brightness: constant base so all directions are clearly coloured. + float val = 0.72f; + + // HSV → RGB + float h6 = fract(hue) * 6.0f; + float f = fract(h6); + float p = val * (1.0f - sat); + float q = val * (1.0f - sat * f); + float tv = val * (1.0f - sat * (1.0f - f)); + int s6 = int(h6); + float3 rgb; + if (s6 == 0) rgb = float3(val, tv, p ); + else if (s6 == 1) rgb = float3(q, val, p ); + else if (s6 == 2) rgb = float3(p, val, tv ); + else if (s6 == 3) rgb = float3(p, q, val); + else if (s6 == 4) rgb = float3(tv, p, val); + else rgb = float3(val, p, q ); + + // Thin specular highlight so the surface still reads as 3-D. + float3 L = normalize(float3(0.6f, 0.8f, 0.3f)); + float3 H = normalize(L + viewDir); + float spec = pow(saturate(dot(normal, H)), 60.0f) * 0.55f; + + return saturate(rgb + spec); +} + +// ── Fragment shader ─────────────────────────────────────────────────────────── +struct MetaballFragOut { + float4 color [[color(0)]]; + float depth [[depth(any)]]; +}; + +fragment MetaballFragOut metaballFragment( + MetaballVertexOut in [[stage_in]], + constant MetaballUniforms &uniforms [[buffer(0)]], + constant float4x4 *viewToWorldTransforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]]) +{ + uint viewIndex = min(in.viewIndex, uniforms.viewCount - 1u); + + // Eye world position from view-to-world matrix column 3. + float3 eyeWorld = viewToWorldTransforms[viewIndex][3].xyz; + float3 rayDir = normalize(in.worldPos - eyeWorld); + + // Work in object-local space. + float3 localEye = eyeWorld - uniforms.objectCenter.xyz; + + // Clip marching to the bounding sphere. + float2 bounds = raySphereHit(localEye, rayDir, uniforms.boundingRadius); + if (bounds.x > bounds.y || bounds.y < 0.0f) { + discard_fragment(); + } + + // Pre-compute all ball centres once per pixel (avoids redundant trig in loop). + float3 centers[BALL_COUNT]; + for (uint i = 0u; i < kBallCount; i++) { + centers[i] = ballCenter(i, uniforms.time); + } + + // Sphere-tracing ray march. + const int kMaxSteps = 64; + const float kHitThreshold = 0.0018f; + + float t = max(bounds.x, 0.001f); + float tMax = bounds.y; + float3 hitPos = localEye; + bool hit = false; + + for (int step = 0; step < kMaxSteps; step++) { + float3 p = localEye + rayDir * t; + float d = sdfFromCenters(p, centers); + + if (d < kHitThreshold) { + hitPos = p; + hit = true; + break; + } + + // Step by 85 % of the SDF distance (conservative to avoid over-stepping). + t += max(d * 0.85f, kHitThreshold); + if (t >= tMax) break; + } + + if (!hit) { + discard_fragment(); + } + + float3 normal = normalFromCenters(hitPos, centers); + float3 worldHit = hitPos + uniforms.objectCenter.xyz; + float3 viewDir = normalize(eyeWorld - worldHit); + + float3 color = directionShade(normal, viewDir); + + // Write depth at the actual surface for correct compositing. + float4 clipHit = vpMatrices[viewIndex] * float4(worldHit, 1.0f); + float ndcZ = clipHit.z / clipHit.w; + + MetaballFragOut out; + out.color = float4(color, 1.0f); + out.depth = clamp(ndcZ, 0.0f, 1.0f); + return out; +} diff --git a/vr-dive/Demos/Metaball/MetaballTypes.swift b/vr-dive/Demos/Metaball/MetaballTypes.swift new file mode 100644 index 0000000..1d5574e --- /dev/null +++ b/vr-dive/Demos/Metaball/MetaballTypes.swift @@ -0,0 +1,9 @@ +import simd + +struct MetaballUniforms { + var time: Float + var viewCount: UInt32 + var boundingRadius: Float + var padding: Float = 0 + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/MilosRose/MilosRoseRenderer.swift b/vr-dive/Demos/MilosRose/MilosRoseRenderer.swift new file mode 100644 index 0000000..b0cf4ae --- /dev/null +++ b/vr-dive/Demos/MilosRose/MilosRoseRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// MilosRoseRenderer.swift +// +// Cube-container adaptation of ShaderToy "Milo's Rose" (XsdyWr). +// 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 MilosRoseRenderer: VisualPatternController { + let identifier: VisualPatternKind = .milosRose + 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 = MilosRoseRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try MilosRoseRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = MilosRoseRenderer.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 = MilosRoseUniforms( + 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 MilosRoseRenderer { + 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: "milosRoseVertex") + desc.fragmentFunction = library.makeFunction(name: "milosRoseFragment") + 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/MilosRose/MilosRoseShaders.metal b/vr-dive/Demos/MilosRose/MilosRoseShaders.metal new file mode 100644 index 0000000..d661f31 --- /dev/null +++ b/vr-dive/Demos/MilosRose/MilosRoseShaders.metal @@ -0,0 +1,230 @@ +// MilosRoseShaders.metal +// Adapted from ShaderToy "Milo's Rose". +// Source: https://www.shadertoy.com/view/XsdyWr +// +// Metal adaptation notes: +// - The original shader built a synthetic camera from fragCoord. This version +// reconstructs the real per-eye world ray, intersects it with a 2 m cube +// container, and starts marching at the visible cube surface or at the eye +// when the viewer is inside the cube. +// - The rose SDF is evaluated beyond the container entry plane, so the +// simulated petals are not clipped to the cube volume. +// - GLSL matrix constructors and implicit scalar/vector operations are expanded +// into explicit Metal helpers. + +#include +using namespace metal; + +struct MilosRoseUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct MilosRoseVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float MR_PI2 = 6.28318530718f; +static constant float3 MR_BOX_HALF = float3(1.0f); +static constant float MR_STOP_THRESHOLD = 0.01f; +static constant float MR_GRAD_STEP = 0.01f; +static constant float MR_TRACE_EPSILON = 0.0015f; +static constant float MR_CLIP_FAR = 14.0f; +static constant float MR_SCENE_SCALE = 0.55f; +static constant int MR_MAX_ITERATIONS = 128; + +vertex MilosRoseVertexOut milosRoseVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant MilosRoseUniforms &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; + + MilosRoseVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3x3 mrRotationXY(float2 angle) { + float2 c = cos(angle); + float2 s = sin(angle); + + return float3x3( + float3(c.y, 0.0f, -s.y), + float3(s.y * s.x, c.x, c.y * s.x), + float3(s.y * c.x, -s.x, c.y * c.x)); +} + +static float2 mrFaceUV(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 mrBoxIntersect(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 mrOpI(float d1, float d2) { + return max(d1, d2); +} + +static float mrOpU(float d1, float d2) { + return min(d1, d2); +} + +static float mrOpS(float d1, float d2) { + return max(-d1, d2); +} + +static float mrSdPetal(float3 p, float s) { + p = p * float3(0.8f, 1.5f, 0.8f) + float3(0.1f, 0.0f, 0.0f); + float2 q = float2(length(p.xz), p.y); + + float lower = length(q) - 1.0f; + lower = mrOpS(length(q) - 0.97f, lower); + lower = mrOpI(lower, q.y); + + float upper = length(q - float2(s, 0.0f)) + 1.0f - s; + upper = mrOpS(upper, length(q - float2(s, 0.0f)) + 0.97f - s); + upper = mrOpI(upper, -q.y); + upper = mrOpI(upper, q.x - 2.0f); + + float region = length(p - float3(1.0f, 0.0f, 0.0f)) - 1.0f; + return mrOpI(mrOpU(upper, lower), region); +} + +static float mrMap(float3 p) { + float d = 1000.0f; + float s = 2.0f; + float3x3 r = mrRotationXY(float2(0.1f, MR_PI2 * 0.618034f)); + r = r * float3x3( + float3(1.08f, 0.0f, 0.0f), + float3(0.0f, 0.995f, 0.0f), + float3(0.0f, 0.0f, 1.08f)); + + for (int i = 0; i < 21; ++i) { + d = mrOpU(d, mrSdPetal(p, s)); + p = r * p; + p += float3(0.0f, -0.02f, 0.0f); + s *= 1.05f; + } + + return d; +} + +static float3 mrGradient(float3 pos) { + float3 dx = float3(MR_GRAD_STEP, 0.0f, 0.0f); + float3 dy = float3(0.0f, MR_GRAD_STEP, 0.0f); + float3 dz = float3(0.0f, 0.0f, MR_GRAD_STEP); + return normalize(float3( + mrMap(pos + dx) - mrMap(pos - dx), + mrMap(pos + dy) - mrMap(pos - dy), + mrMap(pos + dz) - mrMap(pos - dz))); +} + +static float mrRayMarching(float3 origin, float3 dir, float start, float end) { + float depth = start; + for (int i = 0; i < MR_MAX_ITERATIONS; ++i) { + float dist = mrMap(origin + dir * depth); + if (dist < MR_STOP_THRESHOLD) { + return depth; + } + depth += dist * 0.3f; + if (depth >= end) { + return end; + } + } + return end; +} + +static float3 mrShading(float3 v, float3 n, float3 eye) { + const float3 lightPos = float3(20.0f, 50.0f, 20.0f); + float3 ev = normalize(v - eye); + float3 matColor = float3(0.65f, 0.0f, 0.0f); + float3 vl = normalize(lightPos - v); + + float diffuse = dot(vl, n) * 0.5f + 0.5f; + float rim = pow(1.0f - max(dot(n, -ev), 0.0f), 2.0f) * 0.15f; + float ao = clamp(v.y * 0.5f + 0.5f, 0.0f, 1.0f); + return (matColor * diffuse + rim) * ao; +} + +fragment float4 milosRoseFragment( + MilosRoseVertexOut in [[stage_in]], + constant MilosRoseUniforms &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) < MR_BOX_HALF - 1.0e-3f); + float2 tBox = mrBoxIntersect(eye, rd, MR_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 + MR_TRACE_EPSILON); + + float3 sceneEye = marchOrigin / MR_SCENE_SCALE; + float3 sceneDir = rd; + float3x3 sceneRot = mrRotationXY(float2(-1.0f + 0.1f * sin(uniforms.time * 0.23f), 1.0f + 0.15f * cos(uniforms.time * 0.17f))); + sceneEye = sceneRot * sceneEye; + sceneDir = sceneRot * sceneDir; + + float depth = mrRayMarching(sceneEye, sceneDir, 0.0f, MR_CLIP_FAR); + float3 pos = sceneEye + sceneDir * depth; + + float2 q = mrFaceUV(hit); + float radial = 1.2f - length(q - 0.5f); + + float3 color; + if (depth >= MR_CLIP_FAR) { + float glow = 0.5f + 0.5f * cos(3.0f * atan2(sceneDir.y, sceneDir.x) + uniforms.time * 0.35f); + color = mix(float3(0.08f, 0.0f, 0.06f), float3(0.2f, 0.0f, 0.1f), glow); + } else { + float3 normal = mrGradient(pos); + color = mrShading(pos, normal, sceneEye); + } + + color *= radial; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/MilosRose/MilosRoseTypes.swift b/vr-dive/Demos/MilosRose/MilosRoseTypes.swift new file mode 100644 index 0000000..a9f3902 --- /dev/null +++ b/vr-dive/Demos/MilosRose/MilosRoseTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct MilosRoseUniforms in MilosRoseShaders.metal. +struct MilosRoseUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/MirrorLooping/MirrorLoopingRenderer.swift b/vr-dive/Demos/MirrorLooping/MirrorLoopingRenderer.swift new file mode 100644 index 0000000..24fd841 --- /dev/null +++ b/vr-dive/Demos/MirrorLooping/MirrorLoopingRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// MirrorLoopingRenderer.swift +// +// Cube-container adaptation of ShaderToy "Mirror Looping" (XXdGDH). +// 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 MirrorLoopingRenderer: VisualPatternController { + let identifier: VisualPatternKind = .mirrorLooping + 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, -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 = MirrorLoopingRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try MirrorLoopingRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = MirrorLoopingRenderer.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.35 + } + + 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 = MirrorLoopingUniforms( + 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 MirrorLoopingRenderer { + 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: "mirrorLoopingVertex") + desc.fragmentFunction = library.makeFunction(name: "mirrorLoopingFragment") + 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/MirrorLooping/MirrorLoopingShaders.metal b/vr-dive/Demos/MirrorLooping/MirrorLoopingShaders.metal new file mode 100644 index 0000000..3c99d6b --- /dev/null +++ b/vr-dive/Demos/MirrorLooping/MirrorLoopingShaders.metal @@ -0,0 +1,297 @@ +// MirrorLoopingShaders.metal +// "Mirror Looping" — cube-container adaptation of ShaderToy "XXdGDH" +// Source: https://www.shadertoy.com/view/XXdGDH +// +// Source notes: +// - The original shader builds a reflective Wythoff polyhedron from three +// mirror planes, then traces repeated reflections through its interior. +// - This version preserves the fold / edge / trace / bounce logic, but replaces +// the original screen-space orbit camera with the real per-eye world ray +// entering a 2 m cube container. +// - The original sampled an environment map, a wall texture and controller +// state from auxiliary channels. This Metal version uses procedural sky and +// wall shading, and animates the truncation parameters directly over time. + +#include +using namespace metal; + +struct MirrorLoopingUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct MirrorLoopingVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct MLState { + float3x3 mirrors; + float3x3 triangle; + float3 baseVertex; +}; + +static constant float ML_PI = 3.141592654f; +static constant float ML_EDGE_THICKNESS = 0.05f; +static constant int ML_MAX_TRACE_STEPS = 128; +static constant int ML_MAX_RAY_BOUNCES = 12; +static constant float ML_EPSILON = 1.0e-4f; +static constant float ML_FAR = 20.0f; +static constant float ML_SIZE = 1.35f; +static constant float3 ML_PQR = float3(2.0f, 3.0f, 3.0f); +static constant float3 ML_BOX_HALF = float3(1.0f); + +vertex MirrorLoopingVertexOut mirrorLoopingVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant MirrorLoopingUniforms &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; + + MirrorLoopingVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float mlMin3(float x, float y, float z) { + return min(x, min(y, z)); +} + +static float mlMax3(float x, float y, float z) { + return max(x, max(y, z)); +} + +static float mlHash21(float2 p) { + p = fract(p * float2(123.34f, 456.21f)); + p += dot(p, p + 34.45f); + return fract(p.x * p.y); +} + +static float3 mlWallAlbedo(float2 uv, float time) { + float2 grid = uv * 4.5f; + float2 cell = fract(grid) - 0.5f; + float panel = smoothstep(0.48f, 0.1f, max(abs(cell.x), abs(cell.y))); + float weave = 0.5f + 0.5f * sin(grid.x * 3.7f + grid.y * 2.8f + time * 0.4f); + float grain = mlHash21(floor(grid * 2.0f)); + float tone = clamp(panel * 0.65f + weave * 0.25f + grain * 0.1f, 0.0f, 1.0f); + return mix(float3(0.07f, 0.08f, 0.1f), float3(0.37f, 0.42f, 0.48f), tone); +} + +static MLState mlInitState(float time) { + MLState state; + float3 c = cos(ML_PI / ML_PQR); + float sp = sin(ML_PI / ML_PQR.x); + float3 m1 = float3(1.0f, 0.0f, 0.0f); + float3 m2 = float3(-c.x, sp, 0.0f); + float x3 = -c.z; + float y3 = -(c.y + c.x * c.z) / sp; + float z3 = sqrt(max(1.0f - x3 * x3 - y3 * y3, 0.0f)); + float3 m3 = float3(x3, y3, z3); + state.mirrors = float3x3(m1, m2, m3); + + float3 t0 = normalize(cross(m2, m3)); + float3 t1 = normalize(cross(m3, m1)); + float3 t2 = normalize(cross(m1, m2)); + state.triangle = float3x3(t0, t1, t2); + + float3 truncation = float3( + 0.5f * sin(time * 1.5f) + 0.5f, + 0.5f * sin(time * 0.8f) + 0.5f, + 0.5f * sin(time * 0.3f) + 0.5f); + + float determinant = dot(m1, cross(m2, m3)); + float3x3 inverseTranspose = float3x3( + cross(m2, m3), + cross(m3, m1), + cross(m1, m2)) / determinant; + state.baseVertex = normalize(inverseTranspose * truncation) * ML_SIZE; + return state; +} + +static float2 mlOuterBoxIntersect(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 mlFold(float3 p, thread const MLState &state) { + for (int i = 0; i < 5; ++i) { + for (int j = 0; j < 3; ++j) { + float3 mirror = state.mirrors[j]; + p -= 2.0f * min(dot(p, mirror), 0.0f) * mirror; + } + } + return p; +} + +static float mlMap(float3 p, thread const MLState &state) { + p = mlFold(p, state) - state.baseVertex; + return mlMax3( + dot(p, state.triangle[0]), + dot(p, state.triangle[1]), + dot(p, state.triangle[2])); +} + +static float3 mlDistEdges(float3 p, thread const MLState &state) { + p = mlFold(p, state) - state.baseVertex; + float3 ed; + for (int i = 0; i < 3; ++i) { + float3 mirror = state.mirrors[i]; + float3 q = p - min(0.0f, dot(p, mirror)) * mirror; + ed[i] = dot(q, q); + } + return sqrt(ed); +} + +static float mlTrace(float3 pos, float3 rd, bool outside, thread const MLState &state) { + float t = 0.0f; + float sgn = outside ? 1.0f : -1.0f; + for (int i = 0; i < ML_MAX_TRACE_STEPS; ++i) { + float d = mlMap(pos + t * rd, state); + if (abs(d) < ML_EPSILON) { + return t; + } + if (t > ML_FAR) { + break; + } + t += sgn * d * 0.9f; + } + return ML_FAR; +} + +static float3 mlGetNormal(float3 pos, thread const MLState &state) { + float3 eps = float3(0.001f, 0.0f, 0.0f); + return normalize(float3( + mlMap(pos + eps.xyy, state) - mlMap(pos - eps.xyy, state), + mlMap(pos + eps.yxy, state) - mlMap(pos - eps.yxy, state), + mlMap(pos + eps.yyx, state) - mlMap(pos - eps.yyx, state))); +} + +static float3 mlBackground(float3 dir, float time) { + float t = clamp(dir.y * 0.5f + 0.5f, 0.0f, 1.0f); + float3 sky = mix(float3(0.015f, 0.02f, 0.03f), float3(0.18f, 0.23f, 0.32f), t); + float3 sunDir = normalize(float3(0.45f, 0.35f, 0.2f)); + float sun = pow(max(dot(dir, sunDir), 0.0f), 80.0f); + float horizon = pow(max(1.0f - abs(dir.y), 0.0f), 5.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x + dir.z) * 12.0f + time * 0.6f); + sky += sun * float3(2.5f, 2.1f, 1.4f); + sky += horizon * shimmer * float3(0.12f, 0.08f, 0.05f); + return 2.2f * sky / max(1.0f - dot(sky, float3(0.2126f, 0.7152f, 0.0722f)) * 0.35f, 0.15f); +} + +static float4 mlWallColor(float3 dir, float3 nor, float3 eds, float time) { + float d = mlMin3(eds.x, eds.y, eds.z); + float3 albedo = pow(mlWallAlbedo(eds.xy * 2.0f, time), float3(2.2f)) * 0.5f; + float lighting = 0.2f + max(dot(nor, normalize(float3(0.8f, 0.5f, 0.0f))), 0.0f); + + if (dot(dir, nor) < 0.0f) { + float f = clamp(d * 1000.0f - 3.0f, 0.0f, 1.0f); + albedo = mix(float3(0.01f), albedo, f); + return float4(albedo * lighting, f); + } + + float m = mlMax3(eds.x, eds.y, eds.z); + float2 a = fract(float2(d, m) * 40.6f + time * float2(0.03f, -0.05f)) - 0.5f; + float aa = dot(a, a); + float b = 0.2f / (aa + 0.2f); + float lightShape = (1.0f - clamp(d * 100.0f - 2.0f, 0.0f, 1.0f)) * b; + float3 emissive = float3(3.5f, 1.8f, 1.0f); + return float4(mix(albedo * lighting, emissive, lightShape), 0.0f); +} + +fragment float4 mirrorLoopingFragment( + MirrorLoopingVertexOut in [[stage_in]], + constant MirrorLoopingUniforms &uniforms [[buffer(0)]], + constant float4x4 *viewToWorld [[buffer(1)]]) +{ + uint vi = min(in.viewIndex, uniforms.viewCount - 1u); + float4x4 v2w = viewToWorld[vi]; + MLState state = mlInitState(uniforms.time); + + 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) < ML_BOX_HALF - 1.0e-3f); + float2 tOuter = mlOuterBoxIntersect(eye, rd, ML_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float entryT = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 pos = eye + rd * (entryT + 0.002f); + float3 color = float3(0.0f); + float3 transmittance = float3(1.0f); + + if (mlMap(pos, state) > 0.0f) { + float t = mlTrace(pos, rd, true, state); + if (t >= ML_FAR) { + float3 bg = mlBackground(rd, uniforms.time); + bg = bg / (bg * 0.5f + 0.5f); + return float4(clamp(bg, 0.0f, 1.0f), 1.0f); + } + + pos += t * rd; + float3 nor = mlGetNormal(pos, state); + float3 reflDir = reflect(rd, nor); + float3 bgColor = mlBackground(reflDir, uniforms.time); + float fresnel = 0.04f + 0.96f * pow(1.0f - max(dot(rd, -nor), 0.0f), 5.0f); + color += bgColor * fresnel; + + float3 eds = mlDistEdges(pos, state); + float d = mlMin3(eds.x, eds.y, eds.z); + if (d < ML_EDGE_THICKNESS) { + float4 wc = mlWallColor(rd, nor, eds, uniforms.time); + float3 result = color * wc.a + wc.rgb; + result = result / (result * 0.5f + 0.5f); + return float4(clamp(result, 0.0f, 1.0f), 1.0f); + } + } + + for (int i = 0; i < ML_MAX_RAY_BOUNCES; ++i) { + float t = mlTrace(pos, rd, false, state); + if (t >= ML_FAR) { + color += transmittance * mlBackground(rd, uniforms.time); + break; + } + + pos += t * rd; + float3 eds = mlDistEdges(pos, state); + float3 nor = mlGetNormal(pos, state); + float d = mlMin3(eds.x, eds.y, eds.z); + if (d < ML_EDGE_THICKNESS) { + color += transmittance * mlWallColor(rd, nor, eds, uniforms.time).rgb; + break; + } + + rd = reflect(rd, nor); + pos += rd * 0.005f; + transmittance *= float3(0.4f, 0.7f, 0.7f); + } + + color = color / (color * 0.5f + 0.5f); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/MirrorLooping/MirrorLoopingTypes.swift b/vr-dive/Demos/MirrorLooping/MirrorLoopingTypes.swift new file mode 100644 index 0000000..93dc154 --- /dev/null +++ b/vr-dive/Demos/MirrorLooping/MirrorLoopingTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct MirrorLoopingUniforms in MirrorLoopingShaders.metal. +struct MirrorLoopingUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/NearLoxodrome/NearLoxodromeRenderer.swift b/vr-dive/Demos/NearLoxodrome/NearLoxodromeRenderer.swift new file mode 100644 index 0000000..a7f8a7b --- /dev/null +++ b/vr-dive/Demos/NearLoxodrome/NearLoxodromeRenderer.swift @@ -0,0 +1,173 @@ +import Metal +import simd + +// NearLoxodromeRenderer.swift +// Source adaptation: Shadertoy "Near Loxodrome" +// https://www.shadertoy.com/view/NcX3RX +// +// The original shader is a full-screen raymarcher with helper macros. This +// version adapts the core spherical spiral / rails scene into a 2 m cube +// container whose surface acts as a portal into the 3D SDF scene. + +final class NearLoxodromeRenderer: VisualPatternController { + let identifier: VisualPatternKind = .nearLoxodrome + 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.02, -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 = NearLoxodromeRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try NearLoxodromeRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = NearLoxodromeRenderer.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.4 + } + + 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 = NearLoxodromeUniforms( + 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 NearLoxodromeRenderer { + 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: "nearLoxodromeVertex") + desc.fragmentFunction = library.makeFunction(name: "nearLoxodromeFragment") + 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)! + } +} \ No newline at end of file diff --git a/vr-dive/Demos/NearLoxodrome/NearLoxodromeShaders.metal b/vr-dive/Demos/NearLoxodrome/NearLoxodromeShaders.metal new file mode 100644 index 0000000..b183360 --- /dev/null +++ b/vr-dive/Demos/NearLoxodrome/NearLoxodromeShaders.metal @@ -0,0 +1,325 @@ +// NearLoxodromeShaders.metal +// Source adaptation: Shadertoy "Near Loxodrome" +// https://www.shadertoy.com/view/NcX3RX +// +// The original shader uses a set of helper macros and types to raymarch a +// spherical spiral / rails composition. This version expands those ideas into +// explicit Metal code and renders them through a 2 m cube portal. + +#include +using namespace metal; + +struct NearLoxodromeUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct NearLoxodromeVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct NLObject { + float distance; + float id; + float3 position; +}; + +vertex NearLoxodromeVertexOut nearLoxodromeVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant NearLoxodromeUniforms &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; + + NearLoxodromeVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static constant float NL_PI = 3.14159265359f; +static constant float NL_FAR = 24.0f; +static constant float NL_PORTAL_DEPTH = 14.0f; +static constant float NL_EPS = 0.0008f; +static constant int NL_MAX_TRACE_STEPS = 176; +static constant int NL_MAX_SHADOW_STEPS = 40; +static constant float3 NL_BOX_HALF = float3(1.0f, 1.0f, 1.0f); +static constant float NL_SCENE_SCALE = 0.82f; + +static constant float NL_ID_NONE = -1.0f; +static constant float NL_ID_FIGURE = 0.0f; +static constant float NL_ID_FIGURE_INSIDE = 1.0f; +static constant float NL_ID_FIGURE_OUTSIDE = 2.0f; +static constant float NL_ID_RAILS = 3.0f; +static constant float NL_ID_CENTER = 4.0f; + +static float nlRoundBox(float3 p, float3 b, float r) { + float3 q = abs(p) - b; + return length(max(q, 0.0f)) + min(max(q.x, max(q.y, q.z)), 0.0f) - r; +} + +static float nlBoxHit(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 float nlRepeat(thread float &x, float c) { + float cell = floor((x + 0.5f * c) / c); + x = fmod(x + 0.5f * c, c) - 0.5f * c; + return cell; +} + +static float2 nlRepeat2(thread float2 &p, float c) { + float2 cell = floor((p + 0.5f * c) / c); + p = fmod(p + 0.5f * c, c) - 0.5f * c; + return cell; +} + +static float3 nlSpiralLocal(float3 p, float branches, float radius, float stepScale, float phase) { + float r = max(length(p), 1e-4f); + float latitude = asin(clamp(p.y / r, -1.0f, 1.0f)); + float longitude = atan2(p.z, p.x); + + float pitch = max(stepScale, 0.12f); + float loxo = branches * log(max(tan(NL_PI * 0.25f + latitude * 0.5f), 1e-4f)) / pitch + phase; + float branchPeriod = 2.0f * NL_PI / branches; + float branchCoord = (longitude - loxo) / branchPeriod; + float branchIndex = round(branchCoord); + float deltaLon = (branchCoord - branchIndex) * branchPeriod; + + float radial = r - radius; + float along = latitude * radius * 4.2f / max(stepScale, 0.2f) + + branchIndex * branchPeriod * radius * max(cos(latitude), 0.18f); + float across = deltaLon * radius * max(cos(latitude), 0.12f); + return float3(radial, along, across); +} + +static NLObject nlMakeObject(float distance, float id, float3 position) { + NLObject object; + object.distance = distance; + object.id = id; + object.position = position; + return object; +} + +static void nlUnion(thread NLObject &a, NLObject b) { + if (b.distance < a.distance) { + a = b; + } +} + +static float nlMap(float3 p, constant NearLoxodromeUniforms &uniforms, thread NLObject &object, thread float &glow) { + float3 sceneP = p / NL_SCENE_SCALE; + object = nlMakeObject(NL_FAR, NL_ID_NONE, sceneP); + + float radius = 1.0f; + float branches = 3.0f; + float stepScale = 0.5f + 0.30f * sin(0.1f * uniforms.time); + float3 q = nlSpiralLocal(sceneP, branches, radius, stepScale, 0.2f * uniforms.time); + + NLObject figure = nlMakeObject(NL_FAR, NL_ID_FIGURE, q); + { + float slab = abs(q.z) - 0.075f; + float inward = q.x; + float d = max(slab, inward); + float figureId = q.z < -0.01f ? NL_ID_FIGURE_INSIDE : (q.z > 0.01f ? NL_ID_FIGURE_OUTSIDE : NL_ID_FIGURE); + figure = nlMakeObject(d, figureId, q); + } + nlUnion(object, figure); + + NLObject rails = nlMakeObject(NL_FAR, NL_ID_RAILS, q); + { + float3 rq = q; + rq.x -= 0.2f; + rq.x = abs(rq.x) - 0.115f; + float d1 = length(rq.xz) - 0.024f; + + float ry = rq.y; + nlRepeat(ry, 0.3f); + rq.y = ry; + float d2 = max(length(rq.yz) - 0.016f, rq.x); + + rails = nlMakeObject(min(d1, d2), NL_ID_RAILS, rq); + } + nlUnion(object, rails); + + NLObject center = nlMakeObject(NL_FAR, NL_ID_CENTER, sceneP); + { + float3 cq = sceneP; + cq.y = abs(cq.y) - radius - 0.2f; + float d = nlRoundBox(cq, float3(0.0f, 0.1f, 0.0f), 0.025f); + glow += 0.001f / (0.01f + d * d); + center = nlMakeObject(d, NL_ID_CENTER, cq); + } + nlUnion(object, center); + + if (object.id != NL_ID_NONE) { + object.distance *= 0.4f * NL_SCENE_SCALE; + } + return object.distance; +} + +static float nlMapOnly(float3 p, constant NearLoxodromeUniforms &uniforms) { + NLObject object; + float glow = 0.0f; + return nlMap(p, uniforms, object, glow); +} + +static float3 nlMapNormal(float3 p, constant NearLoxodromeUniforms &uniforms, float eps) { + float2 e = float2(eps, -eps); + float v1 = nlMapOnly(p + e.xxx, uniforms); + float v2 = nlMapOnly(p + e.xyy, uniforms); + float v3 = nlMapOnly(p + e.yxy, uniforms); + float v4 = nlMapOnly(p + e.yyx, uniforms); + return normalize(float3(v1 - v2 - v3 - v4) + 2.0f * float3(v2, v3, v4)); +} + +static float nlSoftShadow(float3 origin, float3 direction, constant NearLoxodromeUniforms &uniforms, float farDist, float k) { + float shade = 1.0f; + float t = 0.02f; + for (int i = 0; i < NL_MAX_SHADOW_STEPS && t < farDist; ++i) { + float d = abs(nlMapOnly(origin + direction * t, uniforms)); + shade = min(shade, smoothstep(0.0f, 1.0f, k * d / max(t, 1e-4f))); + t += min(max(d, 0.01f), farDist / float(NL_MAX_SHADOW_STEPS) * 2.0f); + } + return min(max(shade, 0.0f) + 0.5f, 1.0f); +} + +static float3 nlMaterial(NLObject object) { + float3 q = object.position; + if (object.id == NL_ID_FIGURE) { + return float3(1.0f); + } + if (object.id == NL_ID_FIGURE_INSIDE) { + float2 grid = q.xy; + float2 cell = nlRepeat2(grid, 0.14f); + return fmod(cell.x + cell.y, 2.0f) == 0.0f ? float3(1.0f) : float3(1.0f, 0.0f, 0.0f); + } + if (object.id == NL_ID_FIGURE_OUTSIDE) { + float2 grid = q.xy; + float2 cell = nlRepeat2(grid, 0.14f); + return fmod(cell.x + cell.y, 2.0f) == 0.0f ? float3(1.0f) : float3(0.0f, 1.0f, 0.0f); + } + if (object.id == NL_ID_RAILS) { + float railY = q.y; + float cell = nlRepeat(railY, 0.14f); + return fmod(cell, 2.0f) == 0.0f ? float3(0.5f) : float3(1.0f, 0.6f, 0.2f); + } + return float3(1.0f); +} + +fragment float4 nearLoxodromeFragment( + NearLoxodromeVertexOut in [[stage_in]], + constant NearLoxodromeUniforms &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) < (NL_BOX_HALF - 1e-3f)); + float3 entryNormal; + float entryT = nlBoxHit(eye, rd, NL_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 / NL_BOX_HALF.xy + + entryPoint.yz * faceNormal.x / NL_BOX_HALF.yz + + entryPoint.zx * faceNormal.y / NL_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.002f) : (entryPoint + rd * 0.002f); + float maxDistance; + if (insideBox) { + maxDistance = NL_FAR; + } else { + float3 exitNormal; + float throughCube = nlBoxHit(marchOrigin, rd, NL_BOX_HALF, exitNormal, false); + if (throughCube < 0.0f) { + throughCube = 4.0f; + } + maxDistance = throughCube + NL_PORTAL_DEPTH; + } + + float glow = 0.0f; + float distanceTraveled = 0.01f; + NLObject hitObject = nlMakeObject(NL_FAR, NL_ID_NONE, marchOrigin); + bool hit = false; + + for (int i = 0; i < NL_MAX_TRACE_STEPS; ++i) { + float3 pos = marchOrigin + rd * distanceTraveled; + float d = nlMap(pos, uniforms, hitObject, glow); + if (abs(d) < NL_EPS) { + hit = true; + break; + } + distanceTraveled += max(abs(d) * 0.55f, NL_EPS * 0.5f); + if (distanceTraveled > maxDistance) { + break; + } + } + + float3 glassBase = mix(float3(0.014f, 0.016f, 0.024f), float3(0.05f, 0.08f, 0.14f), 1.0f - faceFade); + glassBase += edgeGlow * float3(0.08f, 0.12f, 0.20f); + float3 color = glassBase; + + if (hit) { + float3 hitPos = marchOrigin + rd * distanceTraveled; + float3 normal = nlMapNormal(hitPos, uniforms, 0.006f); + float3 material = nlMaterial(hitObject); + float3 lightDir = normalize(float3(1.0f, 1.0f, -1.0f)); + float shadow = nlSoftShadow(hitPos + normal * 0.02f, lightDir, uniforms, 8.0f, 128.0f); + float diff = max(dot(lightDir, normal), 0.0f); + float back = max(dot(-lightDir, normal), 0.0f); + float spec = pow(max(dot(reflect(lightDir, normal), rdUnit), 0.0f), 64.0f); + color = material * (0.2f + 0.2f * back + 0.8f * diff * shadow) + 0.8f * spec * shadow; + } + + color += glow * float3(1.0f, 0.8f, 0.6f); + float fresnel = pow(clamp(1.0f - abs(dot(faceNormal, rdUnit)), 0.0f, 1.0f), 3.0f); + color += fresnel * float3(0.05f, 0.08f, 0.12f); + color = clamp(tanh(color), 0.0f, 1.0f); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/NearLoxodrome/NearLoxodromeTypes.swift b/vr-dive/Demos/NearLoxodrome/NearLoxodromeTypes.swift new file mode 100644 index 0000000..c33ee36 --- /dev/null +++ b/vr-dive/Demos/NearLoxodrome/NearLoxodromeTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct NearLoxodromeUniforms in +/// NearLoxodromeShaders.metal. +struct NearLoxodromeUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} \ No newline at end of file diff --git a/vr-dive/Demos/NeonShells/NeonShellsRenderer.swift b/vr-dive/Demos/NeonShells/NeonShellsRenderer.swift new file mode 100644 index 0000000..0aeb4b7 --- /dev/null +++ b/vr-dive/Demos/NeonShells/NeonShellsRenderer.swift @@ -0,0 +1,176 @@ +import Metal +import simd + +// NeonShellsRenderer.swift +// 3D cube-container adaptation of "Neon Shells" (ShaderToy scjSRt). +// Original: https://www.shadertoy.com/view/scjSRt + +final class NeonShellsRenderer: VisualPatternController { + let identifier: VisualPatternKind = .neonShells + 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 = NeonShellsRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0)) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try NeonShellsRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = NeonShellsRenderer.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 = NeonShellsUniforms( + 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 NeonShellsRenderer { + 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: "neonShellsVertex") + desc.fragmentFunction = library.makeFunction(name: "neonShellsFragment") + 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/NeonShells/NeonShellsShaders.metal b/vr-dive/Demos/NeonShells/NeonShellsShaders.metal new file mode 100644 index 0000000..3fa1746 --- /dev/null +++ b/vr-dive/Demos/NeonShells/NeonShellsShaders.metal @@ -0,0 +1,234 @@ +// NeonShellsShaders.metal +// 3D visionOS adaptation of "Neon Shells" (ShaderToy scjSRt). +// +// Original GLSL: +// https://www.shadertoy.com/view/scjSRt +// Ported to Metal / visionOS cube-container by the vr-dive project. +// +// Technique: Volumetric accumulation ray march (50 steps) through an infinite +// fractal lattice. In the original 2D shader, p = t * r so the twist amount +// uses the radial distance from the virtual camera origin, not the segment +// distance from a box entry point. The cube adaptation preserves that by +// evaluating a world-space field whose shell twist is driven by |scenePos|. +// Color is accumulated from a per-step palette and tone-mapped with tanh. +// +// GLSL → Metal translation notes: +// • GLSL `p.xz *= mat2(cos(A + vec4(0,11,33,0)))` is row-vector form (v = v * M). +// Metal equivalent: `p.xz = nsRot(A) * p.xz` (col-vector form). +// Matrix M has row-vec result: +// new.x = p.x*cos(A) + p.z*cos(A+11) +// new.z = p.x*cos(A+33) + p.z*cos(A) +// Metal M*v with col0=[c0,c2], col1=[c1,c0]: +// (M*v).x = c0*v.x + c1*v.z = cos(A)*p.x + cos(A+11)*p.z ✓ +// (M*v).y = c2*v.x + c0*v.z = cos(A+33)*p.x + cos(A)*p.z ✓ +// • `p.yz + p.x` — GLSL scalar broadcast to float2; Metal supports this ✓ +// • `p = (p.x < p.y) ? p.zxy : p.zyx` — scalar bool ternary on float3; +// Metal supports scalar-condition ternary selecting float3 values ✓ +// • `fract()` — identical semantics in both GLSL and Metal ✓ +// • Original `r.xy *= mat2(...)` is preserved by rotating sample positions in +// xy. Since p = t*r in the source, rotating r is equivalent to rotating p. +// • `5e1` in GLSL = 50.0 in Metal. + +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Shared types (must match NeonShellsTypes.swift) +// --------------------------------------------------------------------------- + +struct NeonShellsUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct NeonShellsVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Rotation helper +// +// GLSL original: `p.xz *= mat2(cos(A + vec4(0,11,33,0)))` (row-vec form) +// Metal col-vec form: `p.xz = nsRot(A) * p.xz` +// +// float2x2 is column-major: nsRot(A) = float2x2(col0, col1) +// col0 = [cos(A), cos(A+33)] +// col1 = [cos(A+11), cos(A) ] +// +// Multiply M*v: +// result.x = cos(A)*v.x + cos(A+11)*v.z +// result.y = cos(A+33)*v.x + cos(A)*v.z ← assigned back to p.xz ✓ +// --------------------------------------------------------------------------- + +static float2x2 nsRot(float a) { + float c0 = cos(a), c1 = cos(a + 11.0f), c2 = cos(a + 33.0f); + return float2x2(float2(c0, c2), float2(c1, c0)); +} + +// Same matrix family as the original camera rotation: +// GLSL r.xy *= mat2(cos(a + vec4(0,11,33,0))) == rotate sample position.xy. +static float2x2 nsCameraRot(float a) { + float c0 = cos(a), c1 = cos(a + 11.0f), c2 = cos(a + 33.0f); + return float2x2(float2(c0, c2), float2(c1, c0)); +} + +// --------------------------------------------------------------------------- +// Axis-aligned box intersection; returns (tNear, tFar) +// --------------------------------------------------------------------------- + +static float2 nsBoxIntersect(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)); +} + +// --------------------------------------------------------------------------- +// Vertex +// --------------------------------------------------------------------------- + +vertex NeonShellsVertexOut neonShellsVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant NeonShellsUniforms &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; + + NeonShellsVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment — volumetric fractal-lattice accumulation march +// --------------------------------------------------------------------------- + +fragment float4 neonShellsFragment( + NeonShellsVertexOut in [[stage_in]], + constant NeonShellsUniforms &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); + + // Camera and surface 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 + bool insideBox = all(abs(eye) < halfExt - 1.0e-3f); + float2 tBox = nsBoxIntersect(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(); } + + // Map cube-local ray into scene space. + // sceneScale=1.6 keeps the repeating unit-sized folds legible inside the + // 2 m cube while staying closer to the original camera-origin composition. + const float sceneScale = 1.6f; + + // Flip z: ShaderToy content runs in +Z; cube local -Z must map to scene +Z. + float3 ro_entry = (eye + viewDir * (tStart + 0.001f)); + float3 ro_s = float3(ro_entry.x, ro_entry.y, -ro_entry.z) * sceneScale; + float3 rd_s = float3(viewDir.x, viewDir.y, -viewDir.z); + + // Maximum march budget in scene units + float maxDist = (tEnd - tStart) * sceneScale; + + // ----------------------------------------------------------------------- + // Volumetric accumulation loop — 50 iterations matching original + // + // Original loop structure: + // for (O*=i; i++<50.; t+=v) where i=0 initially, so first iter i=1. + // O*=0 zeros the output before the first iteration. + // ----------------------------------------------------------------------- + float t = 0.0f; + float4 O = float4(0.0f); + + for (int ii = 1; ii <= 50; ii++) { + if (t > maxDist) break; + + // Position along the ray in scene space. + float3 scenePos = ro_s + rd_s * t; + + // Preserve the original camera rotation. In the source, p = t * r, so + // rotating r.xy is exactly equivalent to rotating p.xy before the rest + // of the field evaluation. + scenePos.xy = nsCameraRot(uniforms.time * 0.1f) * scenePos.xy; + + // Original shader invariant: p = t * r with |r|=1, therefore t = |p|. + // The shell twist must use radial distance from the virtual origin, + // not box-entry march distance. This is the key shape-restoring change. + float3 p = scenePos; + float radialT = length(scenePos); + + // Rotate xz plane by radial distance from the virtual camera origin. + // GLSL: p.xz *= mat2(cos(t*0.5 + vec4(0,11,33,0))) with t = |p|. + p.xz = nsRot(radialT * 0.5f) * p.xz; + + // Time-based forward movement (fly through the fractal tunnel) + p.z -= uniforms.time * 0.2f; + + // Fractal fold — GLSL: p = fract(p.zyx - 0.5) - 0.5 + // Swizzle p.zyx is an rvalue; Metal fract() is identical to GLSL ✓ + p = fract(p.zyx - 0.5f) - 0.5f; + + // Inner fold: 7 iterations of abs + conditional swap + scale + shift + // GLSL: p = (p.x < p.y) ? p.zxy : p.zyx + // Metal: scalar bool ternary selecting between two float3 swizzles ✓ + for (int j = 0; j < 7; j++) { + p = abs(p); + p = (p.x < p.y) ? p.zxy : p.zyx; + p = p * 1.5f; + p.x -= 1.0f; + } + + // Distance measure. + // length(p.yz + p.x): p.yz is float2, p.x is float — scalar broadcast ✓ + float v = abs(min(length(p.yz + p.x), length(p.xy)) + 0.005f) / 50.0f; + + // Guard against degenerate step that would cause NaN / Inf or stall. + // Keep the floor tiny so the march shape stays faithful to the source. + v = max(v, 1.0e-5f); + + // Color accumulation — palette cycles with loop index + // GLSL: exp(sin(i * 0.1 * vec4(1,2,3,1))) / v + float fi = float(ii); + float4 phase = fi * 0.1f * float4(1.0f, 2.0f, 3.0f, 1.0f); + O += exp(sin(phase)) / v; + + t += v; + } + + // Tone-map: tanh(O / 2e4) — matches original + float3 col = tanh(O.rgb / 2.0e4f); + return float4(col, 1.0f); +} diff --git a/vr-dive/Demos/NeonShells/NeonShellsTypes.swift b/vr-dive/Demos/NeonShells/NeonShellsTypes.swift new file mode 100644 index 0000000..e950937 --- /dev/null +++ b/vr-dive/Demos/NeonShells/NeonShellsTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct NeonShellsUniforms in NeonShellsShaders.metal. +struct NeonShellsUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/NovaMarble/NovaMarbleRenderer.swift b/vr-dive/Demos/NovaMarble/NovaMarbleRenderer.swift new file mode 100644 index 0000000..d3ad6a8 --- /dev/null +++ b/vr-dive/Demos/NovaMarble/NovaMarbleRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// NovaMarbleRenderer.swift +// +// Cube-container adaptation of ShaderToy "Nova Marble" (MtdGD8). +// 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 NovaMarbleRenderer: VisualPatternController { + let identifier: VisualPatternKind = .novaMarble + 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 = NovaMarbleRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try NovaMarbleRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = NovaMarbleRenderer.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 = NovaMarbleUniforms( + 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 NovaMarbleRenderer { + 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: "novaMarbleVertex") + desc.fragmentFunction = library.makeFunction(name: "novaMarbleFragment") + 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/NovaMarble/NovaMarbleShaders.metal b/vr-dive/Demos/NovaMarble/NovaMarbleShaders.metal new file mode 100644 index 0000000..d9f7ae5 --- /dev/null +++ b/vr-dive/Demos/NovaMarble/NovaMarbleShaders.metal @@ -0,0 +1,204 @@ +// NovaMarbleShaders.metal +// "Nova Marble" — cube-container adaptation of ShaderToy "MtdGD8" +// Source: https://www.shadertoy.com/view/MtdGD8 +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Source notes: +// - The original shader is a variant of Playing marble with a time-warped +// volumetric fractal and a bluer accumulation ramp. +// - This version keeps the glass sphere, internal volume march and reflective +// shell behavior, but reconstructs the ray from the real per-eye camera and +// enters through a visible 2 m cube container. +// - The marble itself is evaluated in scene space and is not clipped by the +// cube bounds. + +#include +using namespace metal; + +struct NovaMarbleUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct NovaMarbleVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float NM_SPHERE_RADIUS = 2.0f; +static constant float3 NM_BOX_HALF = float3(1.0f); + +vertex NovaMarbleVertexOut novaMarbleVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant NovaMarbleUniforms &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; + + NovaMarbleVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 csqr(float2 a) { + return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y); +} + +static float2 nmRotate(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 nmRotateScene(float3 p, float time) { + p.yz = nmRotate(p.yz, time * 0.27f); + p.xz = nmRotate(p.xz, time * 0.25f + 0.35f); + return p; +} + +static float2 sphereIntersect(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 nmMap(float3 p, float time) { + float res = 0.0f; + float3 c = p; + float wobbleA = cos(time * 0.15f + 1.6f) * 0.15f; + float wobbleB = cos(time * 0.15f) * 0.15f; + for (int i = 0; i < 10; ++i) { + p = 0.7f * abs(p + wobbleA) / max(dot(p, p), 1.0e-4f) - 0.7f + wobbleB; + p.yz = csqr(p.yz); + p = p.zxy; + res += exp(-19.0f * abs(dot(p, c))); + } + return res * 0.5f; +} + +static float3 nmEnvironment(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), 5.0f); + float sun = pow(max(dot(dir, normalize(float3(0.33f, 0.46f, -0.82f))), 0.0f), 64.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x + dir.z) * 13.0f + time * 0.35f); + float3 sky = mix(float3(0.015f, 0.025f, 0.06f), float3(0.12f, 0.18f, 0.34f), skyMix); + sky += float3(0.08f, 0.16f, 0.34f) * horizon * shimmer * 0.45f; + sky += float3(1.0f, 0.9f, 0.82f) * sun; + return clamp(sky, 0.0f, 2.5f); +} + +static float2 nmBoxIntersect(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 nmFaceUV(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 raymarch(float3 ro, float3 rd, float2 tminmax, float time) { + float t = tminmax.x; + float dt = 0.1f - 0.075f * cos(time * 0.025f); + float3 col = float3(0.0f); + float c = 0.0f; + for (int i = 0; i < 64; ++i) { + t += dt * exp(-2.0f * c); + if (t > tminmax.y) { + break; + } + + c = nmMap(ro + t * rd, time); + col = 0.99f * col + 0.08f * float3(c * c * c, c * c, c); + } + return col; +} + +fragment float4 novaMarbleFragment( + NovaMarbleVertexOut in [[stage_in]], + constant NovaMarbleUniforms &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) < NM_BOX_HALF - 1.0e-3f); + float2 tOuter = nmBoxIntersect(eye, rd, NM_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + const float sceneScale = 2.2f; + float3 ro = (eye + rd * (tStart + 0.001f)) * sceneScale; + float3 marchDir = normalize(rd) * 0.975f; + ro = nmRotateScene(ro, uniforms.time); + marchDir = normalize(nmRotateScene(marchDir, uniforms.time)); + + float2 tmm = sphereIntersect(ro, marchDir, float4(0.0f, 0.0f, 0.0f, NM_SPHERE_RADIUS)); + float3 col = float3(0.0f); + + if (tmm.x < 0.0f && tmm.y < 0.0f) { + col = nmEnvironment(marchDir, uniforms.time); + } else { + float tNear = max(tmm.x, 0.0f); + float tFar = max(tmm.y, tNear); + col = raymarch(ro, marchDir, float2(tNear, tFar), uniforms.time); + + float tSurface = (tmm.x > 0.0f) ? tmm.x : tmm.y; + float3 hitPos = ro + tSurface * marchDir; + float3 nor = hitPos / NM_SPHERE_RADIUS; + float3 reflected = reflect(marchDir, nor); + float fre = pow(0.5f + clamp(dot(reflected, marchDir), 0.0f, 1.0f), 3.0f) * 1.3f; + col += nmEnvironment(reflected, uniforms.time) * fre; + } + + float2 faceUV = nmFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.2f * dot(faceUV, faceUV); + col = 0.5f * log(1.0f + col); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/NovaMarble/NovaMarbleTypes.swift b/vr-dive/Demos/NovaMarble/NovaMarbleTypes.swift new file mode 100644 index 0000000..98fe229 --- /dev/null +++ b/vr-dive/Demos/NovaMarble/NovaMarbleTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct NovaMarbleUniforms in NovaMarbleShaders.metal. +struct NovaMarbleUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeRenderer.swift b/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeRenderer.swift new file mode 100644 index 0000000..31acb99 --- /dev/null +++ b/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeRenderer.swift @@ -0,0 +1,171 @@ +import Metal +import simd + +// OrbitalSphereCubeRenderer.swift +// +// Original implementation for a cube-portal orbital sphere scene. +// Visual inspiration requested from ShaderToy llj3Rz: +// https://www.shadertoy.com/view/llj3Rz +// This implementation is original and does not reuse source code from the +// reference shader. The original used external texture resources; this version +// intentionally avoids those resources and uses only procedural shading. + +final class OrbitalSphereCubeRenderer: VisualPatternController { + let identifier: VisualPatternKind = .orbitalSphereCube + 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.55 + 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 = OrbitalSphereCubeRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try OrbitalSphereCubeRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = OrbitalSphereCubeRenderer.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 = OrbitalSphereCubeUniforms( + 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 OrbitalSphereCubeRenderer { + 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: "orbitalSphereCubeVertex") + desc.fragmentFunction = library.makeFunction(name: "orbitalSphereCubeFragment") + 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/OrbitalSphereCube/OrbitalSphereCubeShaders.metal b/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeShaders.metal new file mode 100644 index 0000000..2d28dc7 --- /dev/null +++ b/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeShaders.metal @@ -0,0 +1,235 @@ +// OrbitalSphereCubeShaders.metal +// +// Original cube-portal orbital sphere scene. +// Visual inspiration requested from ShaderToy llj3Rz: +// https://www.shadertoy.com/view/llj3Rz +// This implementation is original and does not reuse source code from the +// reference shader. The original used external resources; this version uses +// only procedural background, surface, and wire shading. + +#include +using namespace metal; + +#define OSC_PI 3.14159265f +#define OSC_SCENE_SCALE 7.5f +#define OSC_SPHERE_RAD 1.05f + +struct OrbitalSphereCubeUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct OrbitalSphereCubeVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex OrbitalSphereCubeVertexOut orbitalSphereCubeVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant OrbitalSphereCubeUniforms &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; + + OrbitalSphereCubeVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float osc_hash21(float2 p) { + float3 p3 = fract(float3(p.x, p.y, p.x) * 0.1031f); + p3 += dot(p3, p3.yzx + 33.33f); + return fract((p3.x + p3.y) * p3.z); +} + +static float osc_sphereIntersect(float3 ro, float3 rd, float4 sph) { + float3 oc = ro - sph.xyz; + float b = dot(rd, oc); + float c = dot(oc, oc) - sph.w * sph.w; + float h = b * b - c; + if (h <= 0.0f) return -1.0f; + return -b - sqrt(h); +} + +static float osc_sphereDistance(float3 ro, float3 rd, float4 sph) { + float3 oc = ro - sph.xyz; + float b = dot(oc, rd); + float h = dot(oc, oc) - b * b; + return sqrt(max(0.0f, h)) - sph.w; +} + +static float osc_sphereSoftShadow(float3 ro, float3 rd, float4 sph, float k) { + float3 oc = sph.xyz - ro; + float b = dot(oc, rd); + float c = dot(oc, oc) - sph.w * sph.w; + float h = b * b - c; + return (b < 0.0f) ? 1.0f : 1.0f - smoothstep(0.0f, 1.0f, k * h / max(b, 1e-4f)); +} + +static float3 osc_sphereNormal(float3 pos, float4 sph) { + return normalize((pos - sph.xyz) / sph.w); +} + +static float osc_noise2(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + f = f * f * (3.0f - 2.0f * f); + float a = osc_hash21(i + float2(0.0f, 0.0f)); + float b = osc_hash21(i + float2(1.0f, 0.0f)); + float c = osc_hash21(i + float2(0.0f, 1.0f)); + float d = osc_hash21(i + float2(1.0f, 1.0f)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +static float2 osc_voronoi(float2 x) { + float2 n = floor(x); + float2 f = fract(x); + float3 m = float3(8.0f); + for (int j = -1; j <= 1; ++j) { + for (int i = -1; i <= 1; ++i) { + float2 g = float2(float(i), float(j)); + float2 o = float2(osc_hash21(n + g), osc_hash21(n + g + 17.0f)); + float2 r = g - f + o; + float d = dot(r, r); + if (d < m.x) { + m = float3(d, o); + } + } + } + return float2(sqrt(m.x), m.y + m.z); +} + +static float3 osc_background(float3 d, float3 lightDir) { + return float3(0.0f); +} + +static float osc_heightMap(float3 pos, float time) { + float2 r = pos.xz; + float swell = 1.0f - 2.0f / (1.0f + 0.32f * dot(r, r)); + return pos.y - swell; +} + +static float osc_rayMarchPlane(float3 ro, float3 rd, float tmax, float time) { + if (tmax <= 0.0f) { + return tmax + 1.0f; + } + + const int coarseSteps = 48; + float prevT = 0.0f; + float prevH = osc_heightMap(ro, time); + float stepSize = tmax / float(coarseSteps); + + for (int i = 1; i <= coarseSteps; ++i) { + float t = min(tmax, stepSize * float(i)); + float h = osc_heightMap(ro + t * rd, time); + if ((prevH <= 0.0f && h >= 0.0f) || (prevH >= 0.0f && h <= 0.0f)) { + float a = prevT; + float b = t; + float ha = prevH; + for (int j = 0; j < 6; ++j) { + float mid = 0.5f * (a + b); + float hm = osc_heightMap(ro + mid * rd, time); + if ((ha <= 0.0f && hm <= 0.0f) || (ha >= 0.0f && hm >= 0.0f)) { + a = mid; + ha = hm; + } else { + b = mid; + } + } + return 0.5f * (a + b); + } + prevT = t; + prevH = h; + } + + return tmax + 1.0f; +} + +static bool osc_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 orbitalSphereCubeFragment( + OrbitalSphereCubeVertexOut in [[stage_in]], + constant OrbitalSphereCubeUniforms &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 (!osc_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float time = uniforms.time * uniforms.travelSpeed; + float3 ro = (roLocal + rdLocal * max(tEntry, 0.0f)) * OSC_SCENE_SCALE; + float3 rd = rdLocal; + float3 lightDir = normalize(float3(0.85f, 0.35f, 0.65f)); + float4 sphere = float4(0.0f, 0.0f, 0.0f, OSC_SPHERE_RAD); + + float3 color = osc_background(rd, lightDir); + float sphereHit = osc_sphereIntersect(ro, rd, sphere); + float tmax = sphereHit > 0.0f ? sphereHit : 20.0f; + + if (sphereHit > 0.0f) { + float3 pos = ro + sphereHit * rd; + float3 nor = osc_sphereNormal(pos, sphere); + (void)pos; + (void)nor; + color = float3(0.028f, 0.105f, 0.185f); + } + + float planeHit = osc_rayMarchPlane(ro, rd, tmax, time); + if (planeHit < tmax) { + float3 pos = ro + planeHit * rd; + float2 scp = sin(2.0f * 6.2831f * pos.xz); + float3 wire = float3(0.0f); + wire += exp(-24.0f * abs(scp.x)); + wire += exp(-24.0f * abs(scp.y)); + color += wire * float3(0.40f, 0.95f, 0.72f) * 0.26f * exp(-0.03f * planeHit * planeHit); + } + + if (dot(rd, sphere.xyz - ro) > 0.0f) { + float d = osc_sphereDistance(ro, rd, sphere); + float3 glow = float3(0.0f); + glow += float3(0.40f, 0.75f, 1.00f) * 0.24f * exp(-3.8f * abs(d)) * step(0.0f, d); + glow += float3(0.45f, 0.80f, 1.00f) * 0.07f * exp(-13.0f * abs(d)); + glow += float3(0.82f, 0.90f, 1.00f) * 0.08f * exp(-150.0f * abs(d)); + color += glow * 0.7f; + } + + color *= smoothstep(0.0f, 2.5f, time + 0.3f); + color = clamp(color, 0.0f, 1.0f); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeTypes.swift b/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeTypes.swift new file mode 100644 index 0000000..74707d7 --- /dev/null +++ b/vr-dive/Demos/OrbitalSphereCube/OrbitalSphereCubeTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct OrbitalSphereCubeUniforms in +/// OrbitalSphereCubeShaders.metal. +struct OrbitalSphereCubeUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/ParticleRain/ParticleRainRenderer.swift b/vr-dive/Demos/ParticleRain/ParticleRainRenderer.swift new file mode 100644 index 0000000..d747d6a --- /dev/null +++ b/vr-dive/Demos/ParticleRain/ParticleRainRenderer.swift @@ -0,0 +1,164 @@ +import Metal +import simd + +// ParticleRainRenderer.swift +// +// Renders a 2 m cube filled with falling light-streak particles. +// Architecture follows StarTrailsRenderer / GlassBoxRenderer: a bounding-box +// mesh acts as the container; the fragment shader does all volumetric work. + +final class ParticleRainRenderer: VisualPatternController { + let identifier: VisualPatternKind = .particleRain + 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 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 = ParticleRainRenderer.makeBox( + device: device, + halfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try ParticleRainRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = ParticleRainRenderer.makeDepthStencilState(device: device) + } + + func updateSimulation(_ context: PatternSimulationContext) { + defer { lastSimulationTime = context.time } + guard let lastSimulationTime else { return } + let delta = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0)) + animationTime += delta * 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 = ParticleRainUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + 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) + } +} + +// MARK: - Geometry & pipeline helpers +extension ParticleRainRenderer { + + fileprivate static func makeBox( + device: MTLDevice, + halfExtents e: SIMD3 + ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) { + typealias V = PRMeshVertex + 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 verts: [V] = [] + verts.reserveCapacity(24) + var indices: [UInt16] = [] + indices.reserveCapacity(36) + for face in faces { + let base = UInt16(verts.count) + for p in face.positions { verts.append(V(position: p, normal: face.normal)) } + indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3]) + } + let vBuf = device.makeBuffer( + bytes: verts, length: MemoryLayout.stride * verts.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: "particleRainVertex") + desc.fragmentFunction = library.makeFunction(name: "particleRainFragment") + 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 // reverse-Z + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} + +// MARK: - Private vertex type +private struct PRMeshVertex { + var position: SIMD3 + var normal: SIMD3 +} diff --git a/vr-dive/Demos/ParticleRain/ParticleRainShaders.metal b/vr-dive/Demos/ParticleRain/ParticleRainShaders.metal new file mode 100644 index 0000000..4119b4d --- /dev/null +++ b/vr-dive/Demos/ParticleRain/ParticleRainShaders.metal @@ -0,0 +1,208 @@ +// ParticleRainShaders.metal +// +// Falling light-streak particles inside a 2 m cube. +// Each particle is a purely-vertical capsule (10–30 cm long) falling along -Y, +// wrapping back to the top when it exits the bottom. No rotation. +// +// Performance design: +// - Integer Murmur3 hash (no sin() in hash) +// - Narrow horizontal cutoff keeps the streaks crisp instead of foggy +// - Fewer particles reduce overdraw and keep the pattern readable +// - N_STEPS=24, N_PARTS=42 + +#include +using namespace metal; + +// ─── Structs ───────────────────────────────────────────────────────────────── + +// Layout must match ParticleRainUniforms in ParticleRainTypes.swift. +struct ParticleRainUniforms { + float time; + uint viewCount; + float pad0; + float pad1; + float4 objectCenter; +}; + +struct PRVertex { + float3 position; + float3 normal; +}; + +struct PRVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ─── Vertex shader ──────────────────────────────────────────────────────────── +vertex PRVertexOut particleRainVertex( + ushort amplificationID [[amplification_id]], + const device PRVertex *vertices [[buffer(0)]], + constant ParticleRainUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + PRVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position + uniforms.objectCenter.xyz; + + PRVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Integer hash (Murmur3 finalizer), result ∈ [0, 1). +static float pr_h(uint n) { + n ^= (n >> 16u); + n *= 0x85ebca6bu; + n ^= (n >> 13u); + n *= 0xc2b2ae35u; + n ^= (n >> 16u); + return float(n) * (1.0f / 4294967296.0f); +} + +// Ray vs axis-aligned box. +static bool pr_boxHit(float3 ro, float3 rd, float3 halfExtents, + thread float &tNear, thread float &tFar) +{ + float3 invRd = 1.0f / rd; + float3 t1 = (-halfExtents - ro) * invRd; + float3 t2 = ( halfExtents - ro) * invRd; + float3 tMin = min(t1, t2); + float3 tMax = max(t1, t2); + tNear = max(max(tMin.x, tMin.y), tMin.z); + tFar = min(min(tMax.x, tMax.y), tMax.z); + if (tNear > tFar || tFar < 0.0f) return false; + tNear = max(tNear, 0.0f); + return true; +} + +// HSV → RGB. +static float3 pr_hsv2rgb(float h, float s, float v) { + float3 c = clamp(abs(fract(h + float3(1.0f, 2.0f/3.0f, 1.0f/3.0f)) * 6.0f - 3.0f) - 1.0f, + 0.0f, 1.0f); + return v * mix(float3(1.0f), c, s); +} + +// Closest distance between a ray ro + rd * t (t >= 0) and a segment [a, b]. +// Returns squared distance and outputs the closest ray/segment parameters. +static float pr_raySegmentDistanceSq( + float3 ro, float3 rd, float3 a, float3 b, + thread float &rayT, thread float &segU) +{ + float3 ab = b - a; + float abLen2 = max(dot(ab, ab), 1e-6f); + float3 ao = ro - a; + float rdAb = dot(rd, ab); + float rdAo = dot(rd, ao); + float abAo = dot(ab, ao); + float denom = abLen2 - rdAb * rdAb; + + float u; + if (denom > 1e-6f) { + u = clamp((abAo - rdAb * rdAo) / denom, 0.0f, 1.0f); + rayT = rdAb * u - rdAo; + } else { + u = clamp(abAo / abLen2, 0.0f, 1.0f); + rayT = dot(a + ab * u - ro, rd); + } + + if (rayT < 0.0f) { + rayT = 0.0f; + u = clamp(abAo / abLen2, 0.0f, 1.0f); + } else { + float3 q = ro + rd * rayT; + u = clamp(dot(q - a, ab) / abLen2, 0.0f, 1.0f); + rayT = max(dot(a + ab * u - ro, rd), 0.0f); + } + + segU = u; + float3 diff = ro + rd * rayT - (a + ab * u); + return dot(diff, diff); +} + +// ─── Fragment shader ────────────────────────────────────────────────────────── +fragment float4 particleRainFragment( + PRVertexOut in [[stage_in]], + constant ParticleRainUniforms &uniforms [[buffer(0)]], + constant float4x4 *v2wMats [[buffer(1)]]) +{ + uint vi = min(in.viewIndex, uniforms.viewCount - 1u); + float4x4 v2w = v2wMats[vi]; + float3 cam = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 center = uniforms.objectCenter.xyz; + + float3 ro = cam - center; + float3 rd = normalize(in.worldPos - cam); + + float tNear, tFar; + if (!pr_boxHit(ro, rd, float3(1.0f), tNear, tFar)) discard_fragment(); + + const int N_PARTS = 48; + const float LINE_CUTOFF = 0.016f; + const float LINE_CUTOFF2 = LINE_CUTOFF * LINE_CUTOFF; + const float BOX_H = 2.0f; + + float3 accumColor = float3(0.0f); + + for (int i = 0; i < N_PARTS; ++i) { + uint base = uint(i) * 6u; + float h1 = pr_h(base + 0u); // x position + float h2 = pr_h(base + 1u); // z position + float h3 = pr_h(base + 2u); // fall speed + float h4 = pr_h(base + 3u); // trail length + float h5 = pr_h(base + 4u); // initial phase (start height) + float h6 = pr_h(base + 5u); // colour category / brightness + + float sx = -0.84f + 1.68f * h1; + float sz = -0.84f + 1.68f * h2; + + // Cheap XY rejection before any segment math. + float dxh = ro.x + rd.x * tNear - sx; + float dzh = ro.z + rd.z * tNear - sz; + float dh2Hint = dxh * dxh + dzh * dzh; + if (dh2Hint > 0.90f) continue; + + float speed = 0.28f + 0.46f * h3; // 0.28 – 0.74 m/s + float trailLen = 0.10f + 0.20f * h4; // 10 – 30 cm + float yHead = 1.0f - fmod(h5 * BOX_H + speed * uniforms.time, BOX_H); + + float3 col; + if (h6 < 0.30f) { + col = pr_hsv2rgb(0.59f + 0.05f * h3, 0.08f + 0.18f * h4, 1.0f); + } else if (h6 < 0.58f) { + col = pr_hsv2rgb(0.50f + 0.07f * h1, 0.32f + 0.28f * h2, 1.0f); + } else if (h6 < 0.82f) { + col = pr_hsv2rgb(0.73f + 0.06f * h3, 0.28f + 0.30f * h4, 1.0f); + } else { + col = pr_hsv2rgb(0.11f + 0.04f * h1, 0.34f + 0.30f * h3, 1.0f); + } + + float brightness = 1.0f + 2.3f * h6 * h6; + + for (int copy = -1; copy <= 1; ++copy) { + float yOffset = float(copy) * BOX_H; + float3 head = float3(sx, yHead + yOffset, sz); + float3 tail = float3(sx, yHead + trailLen + yOffset, sz); + + float rayT; + float segU; + float d2 = pr_raySegmentDistanceSq(ro, rd, head, tail, rayT, segU); + if (rayT < tNear || rayT > tFar || d2 > LINE_CUTOFF2) continue; + + float glow = exp(-d2 * 130000.0f) + exp(-d2 * 18000.0f) * 0.05f; + float fade = 0.22f + 0.78f * pow(1.0f - segU, 0.55f); + float depthFade = exp(-(rayT - tNear) * 0.28f); + accumColor += col * brightness * glow * fade * depthFade; + } + } + + float3 mapped = 1.0f - exp(-accumColor * 2.8f); + mapped = max(mapped, float3(0.001f, 0.001f, 0.002f)); + return float4(mapped, 1.0f); +} diff --git a/vr-dive/Demos/ParticleRain/ParticleRainTypes.swift b/vr-dive/Demos/ParticleRain/ParticleRainTypes.swift new file mode 100644 index 0000000..23301bd --- /dev/null +++ b/vr-dive/Demos/ParticleRain/ParticleRainTypes.swift @@ -0,0 +1,10 @@ +import simd + +// Layout must match ParticleRainUniforms in ParticleRainShaders.metal. +struct ParticleRainUniforms { + var time: Float + var viewCount: UInt32 + var pad0: Float = 0 + var pad1: Float = 0 + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/PathTilesCube/PathTilesCubeRenderer.swift b/vr-dive/Demos/PathTilesCube/PathTilesCubeRenderer.swift new file mode 100644 index 0000000..9e800dc --- /dev/null +++ b/vr-dive/Demos/PathTilesCube/PathTilesCubeRenderer.swift @@ -0,0 +1,171 @@ +import Metal +import simd + +// PathTilesCubeRenderer.swift +// +// Original implementation for a cube-portal raymarched scene of rounded pillars +// and a drifting path through a tiled field. +// Visual inspiration requested from ShaderToy s3fGR8: +// https://www.shadertoy.com/view/s3fGR8 +// This implementation is original and does not reuse source code from the +// reference shader. + +final class PathTilesCubeRenderer: VisualPatternController { + let identifier: VisualPatternKind = .pathTilesCube + 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.6 + private let objectCenter = SIMD3(0.0, -0.05, -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 = PathTilesCubeRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try PathTilesCubeRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = PathTilesCubeRenderer.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 = PathTilesCubeUniforms( + 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 PathTilesCubeRenderer { + 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: "pathTilesCubeVertex") + desc.fragmentFunction = library.makeFunction(name: "pathTilesCubeFragment") + 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/PathTilesCube/PathTilesCubeShaders.metal b/vr-dive/Demos/PathTilesCube/PathTilesCubeShaders.metal new file mode 100644 index 0000000..387b36a --- /dev/null +++ b/vr-dive/Demos/PathTilesCube/PathTilesCubeShaders.metal @@ -0,0 +1,245 @@ +// PathTilesCubeShaders.metal +// +// Original cube-portal raymarch scene of rounded pillars and a drifting path. +// Visual inspiration requested from ShaderToy s3fGR8: +// https://www.shadertoy.com/view/s3fGR8 +// This implementation is original and does not reuse source code from the +// reference shader. + +#include +using namespace metal; + +#define PTC_MAX_STEPS 52 +#define PTC_MAX_DIST 42.0f +#define PTC_HIT_EPS 0.03f +#define PTC_SCENE_SCALE 11.5f +#define PTC_TILE_SIZE 2.6f + +struct PathTilesCubeUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct PathTilesCubeVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex PathTilesCubeVertexOut pathTilesCubeVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant PathTilesCubeUniforms &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; + + PathTilesCubeVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float ptc_hash21(float2 p) { + float3 p3 = fract(float3(p.x, p.y, p.x) * 0.1031f); + p3 += dot(p3, p3.yzx + 33.33f); + return fract((p3.x + p3.y) * p3.z); +} + +static float2 ptc_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 ptc_roundBox(float3 p, float3 b, float r) { + return length(max(abs(p) - b, 0.0f)) - r; +} + +static float2 ptc_path(float z, float time) { + float zz = z * 0.12f + time * 0.22f; + return float2(sin(zz) * cos(zz * 0.53f) * 6.5f, 0.0f); +} + +static float ptc_tileHeight(float2 tileId, float time) { + float seeded = ptc_hash21(tileId * 0.73f); + float pulse = 0.5f + 0.5f * sin(time * 0.9f + seeded * 6.28318f); + return 0.8f + pow(seeded, 1.9f) * 7.5f + pulse * 0.7f; +} + +static float ptc_tileField(float3 p, float time, thread float &matId) { + float floorY = -3.0f; + float d = p.y - floorY; + matId = 2.0f; + + float2 field = p.xz / PTC_TILE_SIZE; + float2 baseId = floor(field); + float2 local = fract(field) - 0.5f; + float best = d; + float bestMat = 2.0f; + + int x0 = local.x > 0.0f ? 0 : -1; + int x1 = x0 + 1; + int z0 = local.y > 0.0f ? 0 : -1; + int z1 = z0 + 1; + + for (int iz = z0; iz <= z1; ++iz) { + for (int ix = x0; ix <= x1; ++ix) { + float2 tileId = baseId + float2(float(ix), float(iz)); + float2 center = (tileId + 0.5f) * PTC_TILE_SIZE; + float2 laneCenter = ptc_path(center.y, time); + float laneDist = abs(center.x - laneCenter.x); + if (laneDist < 1.35f) { + continue; + } + + float h = ptc_tileHeight(tileId, time); + float3 q = p - float3(center.x, floorY + h * 0.5f, center.y); + q.xz = ptc_rot(q.xz, 0.2f * sin(time + tileId.x * 0.7f + tileId.y * 0.35f)); + float pillar = ptc_roundBox(q, float3(0.58f, h * 0.5f, 0.58f), 0.12f); + if (pillar < best) { + best = pillar; + bestMat = 0.0f; + } + + float cap = ptc_roundBox( + p - float3(center.x, floorY + h + 0.35f, center.y), + float3(0.42f, 0.12f, 0.42f), 0.08f); + if (cap < best) { + best = cap; + bestMat = 1.0f; + } + } + } + + matId = bestMat; + return best; +} + +static float ptc_sceneSdf(float3 p, float time, thread float &matId) { + return ptc_tileField(p, time, matId); +} + +static float3 ptc_normal(float3 p, float time) { + const float e = 0.05f; + float matId; + float dx = ptc_sceneSdf(p + float3(e, 0, 0), time, matId) - ptc_sceneSdf(p - float3(e, 0, 0), time, matId); + float dy = ptc_sceneSdf(p + float3(0, e, 0), time, matId) - ptc_sceneSdf(p - float3(0, e, 0), time, matId); + float dz = ptc_sceneSdf(p + float3(0, 0, e), time, matId) - ptc_sceneSdf(p - float3(0, 0, e), time, matId); + return normalize(float3(dx, dy, dz)); +} + +static bool ptc_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 float3 ptc_sky(float3 rd) { + float t = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float3 horizon = float3(0.95f, 0.97f, 1.00f); + float3 mid = float3(0.20f, 0.45f, 0.75f); + float3 zenith = float3(0.02f, 0.05f, 0.12f); + float3 col = mix(horizon, mid, smoothstep(0.0f, 0.5f, t)); + col = mix(col, zenith, smoothstep(0.4f, 1.0f, t)); + col += 0.15f * float3(1.0f, 0.75f, 0.45f) / (1.0f + 18.0f * abs(rd.y - 0.08f)); + return col; +} + +fragment float4 pathTilesCubeFragment( + PathTilesCubeVertexOut in [[stage_in]], + constant PathTilesCubeUniforms &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 (!ptc_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float startT = max(tEntry, 0.0f); + float3 entryLocal = roLocal + rdLocal * startT; + float3 ro = entryLocal * PTC_SCENE_SCALE; + float3 rd = rdLocal; + float time = uniforms.time * uniforms.travelSpeed; + + float t = 0.0f; + float matId = 2.0f; + bool hit = false; + float3 sp = float3(0.0f); + for (int i = 0; i < PTC_MAX_STEPS; ++i) { + if (t > PTC_MAX_DIST) { break; } + float dist = ptc_sceneSdf(ro + rd * t, time, matId); + if (dist < PTC_HIT_EPS) { + hit = true; + sp = ro + rd * t; + break; + } + t += clamp(dist * 0.82f, 0.05f, 1.15f); + } + + float farMix = smoothstep(0.0f, 1.0f, t / PTC_MAX_DIST); + float3 sky = ptc_sky(rd); + float3 color = sky; + + if (hit) { + float3 sn = ptc_normal(sp, time); + float3 lightPos = ro + float3(0.0f, 7.0f, 12.0f); + float3 lv = lightPos - sp; + float ldist = max(length(lv), 0.001f); + float3 ldir = lv / ldist; + float diff = max(dot(sn, ldir), 0.0f); + float spec = pow(max(dot(reflect(-ldir, sn), -rd), 0.0f), 10.0f); + float fres = pow(clamp(1.0f + dot(rd, sn), 0.0f, 1.0f), 1.4f); + + float2 cellId = floor(sp.xz / PTC_TILE_SIZE); + float seed = ptc_hash21(cellId); + float3 base = mix(float3(0.10f, 0.12f, 0.15f), float3(0.00f, 0.80f, 1.00f), step(0.52f, seed)); + base = mix(base, float3(1.0f, 0.0f, 0.4f), step(0.82f, seed)); + if (matId > 1.5f) { + base = float3(0.18f, 0.18f, 0.20f); + } else if (matId > 0.5f) { + base = float3(1.0f, 0.85f, 0.0f); + } + + float atten = 1.0f / (1.0f + 0.014f * ldist * ldist); + float pathGlow = 1.0f / (1.0f + 0.12f * abs(sp.x - ptc_path(sp.z, time).x)); + float3 lit = base * (0.18f + 1.25f * diff) + spec * float3(0.9f, 0.5f, 0.2f); + lit += fres * 0.15f * sky; + lit += pathGlow * float3(0.08f, 0.14f, 0.22f); + color = lit * atten; + color = mix(color, sky, farMix); + } + + float vignette = 1.0f - smoothstep(0.7f, 1.8f, length(in.worldPos.xy - center.xy)); + color *= mix(0.82f, 1.0f, vignette); + color = pow(max(color, 0.0f), float3(0.9f)); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/PathTilesCube/PathTilesCubeTypes.swift b/vr-dive/Demos/PathTilesCube/PathTilesCubeTypes.swift new file mode 100644 index 0000000..daccd80 --- /dev/null +++ b/vr-dive/Demos/PathTilesCube/PathTilesCubeTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct PathTilesCubeUniforms in +/// PathTilesCubeShaders.metal. +struct PathTilesCubeUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/PetalsFractal/PetalsFractalRenderer.swift b/vr-dive/Demos/PetalsFractal/PetalsFractalRenderer.swift new file mode 100644 index 0000000..b9910af --- /dev/null +++ b/vr-dive/Demos/PetalsFractal/PetalsFractalRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// PetalsFractalRenderer.swift +// Cube-container adaptation of ShaderToy "Petals Fractal" (lt2GWw). + +final class PetalsFractalRenderer: VisualPatternController { + let identifier: VisualPatternKind = .petalsFractal + 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.9) + + private var animationTime: Float = 0 + private var lastSimulationTime: Float? + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + let geo = PetalsFractalRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try PetalsFractalRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = PetalsFractalRenderer.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.25 + } + + 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 = PetalsFractalUniforms( + 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 PetalsFractalRenderer { + 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: "petalsFractalVertex") + desc.fragmentFunction = library.makeFunction(name: "petalsFractalFragment") + 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/PetalsFractal/PetalsFractalShaders.metal b/vr-dive/Demos/PetalsFractal/PetalsFractalShaders.metal new file mode 100644 index 0000000..2288a33 --- /dev/null +++ b/vr-dive/Demos/PetalsFractal/PetalsFractalShaders.metal @@ -0,0 +1,173 @@ +// PetalsFractalShaders.metal +// "Petals Fractal" — cube-container adaptation of ShaderToy lt2GWw. +// Source: https://www.shadertoy.com/view/lt2GWw +// Source shader uses a rotating remote ray origin and a reciprocal-fold fractal field. +// This adaptation reconstructs a real per-eye world ray entering a 2 m cube, +// then maps that ray into the original volumetric scene so the pattern remains +// visible from all viewing directions and also when the viewer is inside. + +#include +using namespace metal; + +struct PetalsFractalUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct PetalsFractalVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant int PF_MAX_RAY_STEPS = 64; +static constant float PF_SCALE = 0.3f; +static constant float PF_SIZE = 0.45f; +static constant float PF_INTENSITY = 1.5f; +static constant float3 PF_BOX_HALF = float3(1.0f); +static constant float PF_SCENE_SCALE = 48.0f; + +vertex PetalsFractalVertexOut petalsFractalVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant PetalsFractalUniforms &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; + + PetalsFractalVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3 pfRotationCoord(float3 n, float t) { + float s = sin(t); + float c = cos(t); + float3x3 rotate = float3x3( + float3(c, 0.0f, s), + float3(0.0f, 1.0f, 0.0f), + float3(-s, 0.0f, c)); + return rotate * n; +} + +static float pfPattern(float3 p) { + p *= PF_SCALE; + for (int i = 0; i < 10; ++i) { + p = abs(p) / max(dot(p, p), 1.0e-4f) - float3(PF_SIZE); + } + return dot(p, p) * PF_INTENSITY; +} + +static float pfRender(float3 posOnRay, float3 rayDir) { + float t = 0.0f; + float maxDist = 30.0f; + float d = 0.1f; + + for (int i = 0; i < PF_MAX_RAY_STEPS; ++i) { + if (abs(d) < 0.0001f || t > maxDist) { + break; + } + t += d; + posOnRay += rayDir / (d + 0.35f); + d = pfPattern(posOnRay); + } + + return d; +} + +static float3 pfEnvironment(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.35f, 0.4f, -0.85f))), 0.0f), 48.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x + dir.z) * 10.0f + time * 0.25f); + float3 sky = mix(float3(0.01f, 0.012f, 0.02f), float3(0.08f, 0.10f, 0.16f), skyMix); + sky += float3(0.12f, 0.10f, 0.08f) * horizon * 0.25f * shimmer; + sky += float3(1.0f, 0.96f, 0.9f) * sun; + return clamp(sky, 0.0f, 1.6f); +} + +static float2 pfBoxIntersect(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 pfFaceUV(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); +} + +fragment float4 petalsFractalFragment( + PetalsFractalVertexOut in [[stage_in]], + constant PetalsFractalUniforms &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 viewDir = normalize(surfacePos - eye); + + bool insideOuter = all(abs(eye) < PF_BOX_HALF - 1.0e-3f); + float2 tOuter = pfBoxIntersect(eye, viewDir, PF_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 localOrigin = eye + viewDir * (tStart + 0.001f); + + float time = uniforms.time; + float rotTime = time * 0.5f; + + // Keep the fractal anchored at the cube center so it stays spatially fixed + // relative to the container and is visible from all directions. + float3 sceneDir = normalize(pfRotationCoord(viewDir, rotTime)); + float3 sceneOrigin = pfRotationCoord(localOrigin * PF_SCENE_SCALE, rotTime); + + float t = pfRender(sceneOrigin, sceneDir); + float3 col = float3(0.5f * t * t * t, 0.6f * t * t, 0.7f * t); + col = min(col, 1.0f) - 0.28f * log(col + 1.0f); + col = sqrt(max(col, 0.0f)); + + float trail = pow(clamp(1.0f - abs(dot(viewDir, float3(0.0f, 0.0f, 1.0f))), 0.0f, 1.0f), 2.0f); + float3 bg = pfEnvironment(viewDir, time); + bg += float3(0.12f, 0.08f, 0.18f) * trail * 0.08f; + + float2 faceUV = pfFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + col += bg * 0.18f; + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/PetalsFractal/PetalsFractalTypes.swift b/vr-dive/Demos/PetalsFractal/PetalsFractalTypes.swift new file mode 100644 index 0000000..3143eb0 --- /dev/null +++ b/vr-dive/Demos/PetalsFractal/PetalsFractalTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct PetalsFractalUniforms in +/// PetalsFractalShaders.metal. +struct PetalsFractalUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/PlatonicMirror/PlatonicMirrorRenderer.swift b/vr-dive/Demos/PlatonicMirror/PlatonicMirrorRenderer.swift new file mode 100644 index 0000000..c849ec2 --- /dev/null +++ b/vr-dive/Demos/PlatonicMirror/PlatonicMirrorRenderer.swift @@ -0,0 +1,172 @@ +import Metal +import simd + +// PlatonicMirrorRenderer.swift +// +// Renders a Platonic solid with internal mirror reflections using ray marching. +// A UV-sphere mesh acts as the container; the fragment shader does all ray work. +// +// Shader source: "Let's self reflect" by mrange +// https://www.shadertoy.com/view/XfyXRV (License: CC0) + +final class PlatonicMirrorRenderer: VisualPatternController { + let identifier: VisualPatternKind = .platonicMirror + 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 + + // Local bounding sphere radius. The Platonic solid with poly_zoom=2 fits + // within roughly ±2.8 local units, so 3.1 gives comfortable clearance. + private static let localBoundRadius: Float = 3.1 + + // World-space placement: 0.35 m per local unit, placed ~1.8 m in front. + private let solidScale: Float = 0.35 + private let objectCenter = SIMD3(0.0, -0.15, -1.8) + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + // Vertices are stored in local units; the vertex shader scales by solidScale. + let geo = PlatonicMirrorRenderer.makeUVSphere( + device: device, + radius: PlatonicMirrorRenderer.localBoundRadius, + latSegments: 24, + lonSegments: 48) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try PlatonicMirrorRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = PlatonicMirrorRenderer.makeDepthStencilState(device: device) + } + + func updateSimulation(_ context: PatternSimulationContext) {} + func resetToInitialState() {} + + 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 = PlatonicMirrorUniforms( + time: context.time, + viewCount: UInt32(context.viewData.viewCount), + solidScale: solidScale, + _pad: 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) + } + } + + // Fragment: uniforms, viewToWorld, viewProjection + 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) + } + } + vpMatrices.withUnsafeBytes { + if let base = $0.baseAddress, $0.count > 0 { + encoder.setFragmentBytes(base, length: $0.count, index: 2) + } + } + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: indexCount, + indexType: .uint16, + indexBuffer: indexBuffer, + indexBufferOffset: 0) + } +} + +// MARK: - Geometry & pipeline factory +extension PlatonicMirrorRenderer { + + fileprivate static func makeUVSphere( + device: MTLDevice, radius: Float, latSegments: Int, lonSegments: Int + ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) { + var vertices: [MeshVertex] = [] + var indices: [UInt16] = [] + vertices.reserveCapacity((latSegments + 1) * (lonSegments + 1)) + indices.reserveCapacity(latSegments * lonSegments * 6) + + for lat in 0...latSegments { + let theta = Float(lat) * .pi / Float(latSegments) + let sinT = sin(theta) + let cosT = cos(theta) + for lon in 0...lonSegments { + let phi = Float(lon) * 2 * .pi / Float(lonSegments) + let n = SIMD3(cos(phi) * sinT, cosT, sin(phi) * sinT) + vertices.append(MeshVertex(position: n * radius, normal: n)) + } + } + + let stride = UInt16(lonSegments + 1) + for lat in 0...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: "platonicMirrorVertex") + desc.fragmentFunction = library.makeFunction(name: "platonicMirrorFragment") + 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 // reverse-Z: near = 1, far = 0 + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} diff --git a/vr-dive/Demos/PlatonicMirror/PlatonicMirrorShaders.metal b/vr-dive/Demos/PlatonicMirror/PlatonicMirrorShaders.metal new file mode 100644 index 0000000..b8547f4 --- /dev/null +++ b/vr-dive/Demos/PlatonicMirror/PlatonicMirrorShaders.metal @@ -0,0 +1,412 @@ +// PlatonicMirrorShaders.metal +// +// Source: "Let's self reflect" by mrange +// https://www.shadertoy.com/view/XfyXRV +// License: CC0 +// +// VR adaptation: UV-sphere mesh container; fragment shader reconstructs a world-space +// ray from the actual VR camera position, transforms it to solid-local space, then runs +// the full Platonic-solid ray-march with internal mirror reflections. +// +// Original poly-fold technique by knighty: https://www.shadertoy.com/view/MsKGzw + +#include +using namespace metal; + +// ─── Uniforms ───────────────────────────────────────────────────────────────── +// Layout must match PlatonicMirrorUniforms in PlatonicMirrorTypes.swift. +struct PlatonicMirrorUniforms { + float time; + uint viewCount; + float solidScale; // world metres per local unit + float _pad; + float4 objectCenter; // xyz = world position +}; + +struct MeshVertex { float3 position; float3 normal; }; + +struct PlatonicVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ─── Vertex shader ──────────────────────────────────────────────────────────── +vertex PlatonicVertexOut platonicMirrorVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant PlatonicMirrorUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + MeshVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + // vtx.position is in local units; scale to world space + float3 worldPos = vtx.position * uniforms.solidScale + uniforms.objectCenter.xyz; + + PlatonicVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ─── Tunable constants ──────────────────────────────────────────────────────── +#define PM_PI 3.141592654f +#define ROTATION_SPEED 0.25f + +// Polyhedron parameters (matching the original ShaderToy defaults) +#define POLY_TYPE 3 // [2,5] +#define POLY_U 1.0f +#define POLY_V 0.5f +#define POLY_W 1.0f +#define POLY_ZOOM 2.0f +#define INNER_SPHERE 1.0f +#define REFR_INDEX 0.9f +#define RREFR_INDEX (1.0f / REFR_INDEX) +#define PM_BOUND_RADIUS 3.1f + +// Ray-march budgets +#define MAX_BOUNCES2 4 // increased for deeper internal mirror recursion +#define MAX_MARCHES2 16 // slightly higher to support the extra bounces +#define TOLERANCE2 0.002f +#define NORM_OFF2 0.007f +#define MAX_MARCHES3 24 // allow more iterations for distant views +#define TOLERANCE3 0.002f +#define NORM_OFF3 0.007f + +// ─── Context (replaces GLSL mutable globals g_rot / g_gd) ──────────────────── +struct PlatonicCtx { + float3x3 g_rot; // per-frame rotation applied inside the solid + float2 g_gd; // accumulated minimum (edge_dist, corner_or_inner_dist) + // Precomputed poly-fold constants (depend only on POLY_TYPE, U, V, W) + float3 poly_nc; + float3 poly_pab; + float3 poly_pbc; // normalised + float3 poly_pca; // normalised + float3 poly_p; // normalised blended vertex direction +}; + +// ─── Utilities ──────────────────────────────────────────────────────────────── +// License: WTFPL, author: sam hocevar +inline float3 pm_hsv2rgb(float3 c) { + 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); +} + +// License: MIT, author: Inigo Quilez — rotation matrix aligning z → d +inline float3x3 pm_buildRot(float3 d, float3 z) { + float3 v = cross(z, d); + float c = dot(z, d); + if (c < -0.9999f) { + return float3x3(float3(1,0,0), float3(0,-1,0), float3(0,0,-1)); + } + float k = 1.0f / (1.0f + c); + return float3x3( + float3(v.x*v.x*k + c, v.y*v.x*k - v.z, v.z*v.x*k + v.y), + float3(v.x*v.y*k + v.z, v.y*v.y*k + c, v.z*v.y*k - v.x), + float3(v.x*v.z*k - v.y, v.y*v.z*k + v.x, v.z*v.z*k + c ) + ); +} + +// License: Unknown, author: Matt Taylor — ACES filmic tone-map approximation +inline float3 pm_aces(float3 v) { + v = max(v, 0.0f); v *= 0.6f; + const float a=2.51f, b=0.03f, c=2.43f, d=0.59f, e=0.14f; + return clamp((v*(a*v+b))/(v*(c*v+d)+e), 0.0f, 1.0f); +} + +inline float pm_box2(float2 p, float2 b) { + float2 dd = abs(p) - b; + return length(max(dd, 0.0f)) + min(max(dd.x, dd.y), 0.0f); +} + +// ─── Polyhedron fold (License: Unknown, author: knighty) ───────────────────── +inline void poly_fold(thread float3 &pos, float3 nc) { + for (int i = 0; i < POLY_TYPE; ++i) { + pos.xy = abs(pos.xy); + pos -= 2.0f * min(0.0f, dot(pos, nc)) * nc; + } +} + +// Evaluate the three shape SDF components: (plane, edge, corner) +inline float3 pm_shape(float3 pos, thread const PlatonicCtx &ctx) { + pos = pos * ctx.g_rot; // row-vector × matrix (same as GLSL pos *= g_rot) + pos /= POLY_ZOOM; + poly_fold(pos, ctx.poly_nc); + pos -= ctx.poly_p; + + // poly_plane: maximum of three half-space signed distances + float d0 = max(max(dot(pos, ctx.poly_pab), + dot(pos, ctx.poly_pbc)), + dot(pos, ctx.poly_pca)); + + // poly_edge: distance to the three fold edges + float3 pa = pos - min(0.0f, pos.x) * float3(1,0,0); + float3 pb = pos - min(0.0f, pos.y) * float3(0,1,0); + float3 pc = pos - min(0.0f, dot(pos, ctx.poly_nc)) * ctx.poly_nc; + float d1 = sqrt(min(min(dot(pa,pa), dot(pb,pb)), dot(pc,pc))) - 2e-3f; + + // poly_corner: small sphere at folded-space origin + float d2 = length(pos) - 0.0125f; + + return float3(d0, d1, d2) * POLY_ZOOM; +} + +// ─── Distance fields ────────────────────────────────────────────────────────── +// df2: used inside the solid for bounce ray-marching +static float pm_df2(float3 p, thread PlatonicCtx &ctx) { + float3 ds = pm_shape(p, ctx); + float d2 = ds.y - 5e-3f; + float d0 = min(-ds.x, d2); // inside the solid OR near edge + float d1 = length(p) - INNER_SPHERE; // inner sphere + ctx.g_gd = min(ctx.g_gd, float2(d2, d1)); + return min(d0, d1); +} + +// df3: used outside the solid for the primary ray-march +static float pm_df3(float3 p, thread PlatonicCtx &ctx) { + float3 ds = pm_shape(p, ctx); + ctx.g_gd = min(ctx.g_gd, ds.yz); + return min(min(ds.x, ds.y), ds.z); +} + +// ─── Normal estimation ──────────────────────────────────────────────────────── +static float3 pm_normal2(float3 pos, thread PlatonicCtx &ctx) { + const float2 eps = float2(NORM_OFF2, 0.0f); + float3 n; + n.x = pm_df2(pos+eps.xyy,ctx) - pm_df2(pos-eps.xyy,ctx); + n.y = pm_df2(pos+eps.yxy,ctx) - pm_df2(pos-eps.yxy,ctx); + n.z = pm_df2(pos+eps.yyx,ctx) - pm_df2(pos-eps.yyx,ctx); + return normalize(n); +} + +static float3 pm_normal3(float3 pos, thread PlatonicCtx &ctx) { + const float2 eps = float2(NORM_OFF3, 0.0f); + float3 n; + n.x = pm_df3(pos+eps.xyy,ctx) - pm_df3(pos-eps.xyy,ctx); + n.y = pm_df3(pos+eps.yxy,ctx) - pm_df3(pos-eps.yxy,ctx); + n.z = pm_df3(pos+eps.yyx,ctx) - pm_df3(pos-eps.yyx,ctx); + return normalize(n); +} + +// ─── Ray marchers ───────────────────────────────────────────────────────────── +static float pm_rayMarch2(float3 ro, float3 rd, float tinit, thread PlatonicCtx &ctx) { + float t = tinit; + float2 dti = float2(1e10f, 0.0f); // backstep: (min_d, t_at_min_d) + int i; + for (i = 0; i < MAX_MARCHES2; ++i) { + float d = pm_df2(ro + rd*t, ctx); + if (d < dti.x) { dti = float2(d, t); } + if (d < TOLERANCE2) break; + t += d; + } + if (i == MAX_MARCHES2) { t = dti.y; } + return t; +} + +static float pm_rayMarch3(float3 ro, float3 rd, float tinit, float maxRayLen, + thread PlatonicCtx &ctx, thread int &iter) { + float t = tinit; + int i; + for (i = 0; i < MAX_MARCHES3; ++i) { + float d = pm_df3(ro + rd*t, ctx); + if (d < TOLERANCE3 || t > maxRayLen) break; + t += d; + } + iter = i; + return t; +} + +static bool pm_sphereHit(float3 ro, float3 rd, float radius, + thread float &tNear, thread float &tFar) { + float b = dot(ro, rd); + float c = dot(ro, ro) - radius * radius; + float h = b * b - c; + if (h < 0.0f) { + return false; + } + + float root = sqrt(h); + tNear = -b - root; + tFar = -b + root; + return tFar >= max(tNear, 0.0f); +} + +// ─── Procedural background environment ─────────────────────────────────────── +// sunDir = normalize(-rayOrigin) where original rayOrigin = (0,1,-5) +static constant float3 PM_SUN_DIR = float3(0.0f, -0.19612f, 0.98058f); // normalize(0,-1,5) + +static float3 pm_render0(float3 ro, float3 rd, + float3 sunCol, float3 botCol, float3 topCol) +{ + float3 col = float3(0); + float srd = sign(rd.y); + float tp = -(ro.y - 6.0f) / (abs(rd.y) + 1e-6f); + + if (srd < 0.0f) { + col += botCol * exp(-0.5f * length((ro + tp*rd).xz)); + } + if (srd > 0.0f) { + float3 pos = ro + tp*rd; + float2 pp = pos.xz; + float db = pm_box2(pp, float2(5.0f, 9.0f)) - 3.0f; + col += topCol * rd.y*rd.y * smoothstep(0.25f, 0.0f, db); + col += 0.2f * topCol * exp(-0.5f * max(db, 0.0f)); + col += 0.05f * sqrt(topCol) * max(-db, 0.0f); + } + col += sunCol / (1.001f - dot(PM_SUN_DIR, rd)); + return col; +} + +// ─── Internal bounce reflections (render2) ─────────────────────────────────── +static float3 pm_render2(float3 ro, float3 rd, float db, thread PlatonicCtx &ctx, + float3 sunCol, float3 botCol, float3 topCol, + float3 glowCol1, float3 beerCol) +{ + float3 agg = float3(0); + float ragg = 1.0f; + float tagg = 0.0f; + + for (int bounce = 0; bounce < MAX_BOUNCES2; ++bounce) { + if (ragg < 0.1f) break; + + ctx.g_gd = float2(1e3f); + float t2 = pm_rayMarch2(ro, rd, min(db + 0.05f, 0.3f), ctx); + float2 gd2 = ctx.g_gd; + tagg += t2; + + float3 p2 = ro + rd*t2; + float3 n2 = pm_normal2(p2, ctx); + float3 r2 = reflect(rd, n2); + float3 rr2 = refract(rd, n2, RREFR_INDEX); + float fre2 = 1.0f + dot(n2, rd); + + float3 beer = ragg * exp(0.2f * beerCol * tagg); + + // Edge-proximity glow + float denom = max(gd2.x, 5e-4f + tagg*tagg * 2e-4f / max(ragg, 1e-6f)); + agg += glowCol1 * beer * ((1.0f + tagg*tagg*4e-2f) * 6.0f / denom); + + float3 ocol = 0.2f * beer * pm_render0(p2, rr2, sunCol, botCol, topCol); + if (gd2.y <= TOLERANCE2) { + ragg *= 1.0f - 0.9f * fre2; // hit inner sphere: partial absorption + } else { + agg += ocol; + ragg *= 0.8f; + } + ro = p2; + rd = r2; + db = gd2.x; + } + return agg; +} + +// ─── Outer-surface render (render3) ────────────────────────────────────────── +static float3 pm_render3(float3 ro, float3 rd, float maxRayLen, thread PlatonicCtx &ctx) { + // Colour constants (matching original ShaderToy verbatim) + float3 sunCol = pm_hsv2rgb(float3(0.06f, 0.90f, 1e-2f)); + float3 botCol = pm_hsv2rgb(float3(0.66f, 0.80f, 0.5f)); + float3 topCol = pm_hsv2rgb(float3(0.60f, 0.90f, 1.0f)); + float3 glowCol0 = pm_hsv2rgb(float3(0.05f, 0.7f, 1e-3f)); + float3 glowCol1 = pm_hsv2rgb(float3(0.95f, 0.7f, 1e-3f)); + float3 beerCol = -pm_hsv2rgb(float3(0.65f, 0.7f, 2.0f)); // negative → absorption + + float3 skyCol = pm_render0(ro, rd, sunCol, botCol, topCol); + float3 col = skyCol; + + ctx.g_gd = float2(1e3f); + int iter; + float t1 = pm_rayMarch3(ro, rd, 0.1f, maxRayLen, ctx, iter); + float2 gd1 = ctx.g_gd; + + float3 p1 = ro + t1*rd; + float fre1 = 0.0f; // default for miss + + // Smooth-step fade for rays near the march limit (silhouette anti-alias) + float ifo = mix(0.5f, 1.0f, + smoothstep(1.0f, 0.9f, float(iter) / float(MAX_MARCHES3))); + + if (t1 < maxRayLen) { + // Only compute expensive derivatives on hit fragments + float3 n1 = pm_normal3(p1, ctx); + float3 r1 = reflect(rd, n1); + float3 rr1 = refract(rd, n1, REFR_INDEX); + fre1 = 1.0f + dot(rd, n1); + fre1 *= fre1; + + col = pm_render0(p1, r1, sunCol, botCol, topCol) * (0.5f + 0.5f*fre1) * ifo; + + if (gd1.x > TOLERANCE3 && gd1.y > TOLERANCE3 && length(rr1) > 1e-4f) { + float3 icol = pm_render2(p1, rr1, gd1.x, ctx, + sunCol, botCol, topCol, glowCol1, beerCol); + col += icol * (1.0f - 0.75f*fre1) * ifo; + } + } + + // Outer edge-proximity glow (safe for miss: gd1.x == 1e3, fre1 == 0) + col += (glowCol0 + 1.0f*fre1*glowCol0) / max(gd1.x, 3e-4f); + return col; +} + +// ─── Fragment shader ────────────────────────────────────────────────────────── +fragment float4 platonicMirrorFragment( + PlatonicVertexOut in [[stage_in]], + constant PlatonicMirrorUniforms &uniforms [[buffer(0)]], + constant float4x4 *v2wMats [[buffer(1)]], + constant float4x4 *vpMats [[buffer(2)]]) +{ + 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 sc = uniforms.solidScale; + + // Ray in solid-local space. Direction is scale-invariant for uniform scale. + float3 ro = (camWorld - center) / sc; + float3 rd = normalize(in.worldPos - camWorld); + + // Cull back-hemisphere fragments: the sphere mesh is convex, so any fragment + // whose outward normal faces away from the camera is occluded by the front face. + // discard_fragment() avoids the full ray-march for ~half the sphere pixels. + float3 fragNormal = normalize(in.worldPos - center); + if (dot(rd, fragNormal) > 0.0f) { discard_fragment(); } + + // Per-frame rotation (matches mainImage in the original ShaderToy) + float a = uniforms.time * ROTATION_SPEED; + float sq2 = sqrt(0.5f); + float3 r0 = float3(1.0f, sin(sq2*a), sin(a)); + float3 r1 = float3(cos(sq2*0.913f*a), cos(0.913f*a), 1.0f); + + // Precompute poly-fold constants (depend only on compile-time parameters) + float cospin = cos(PM_PI / float(POLY_TYPE)); + float scospin = sqrt(max(0.75f - cospin*cospin, 0.0f)); + float3 nc = float3(-0.5f, -cospin, scospin); + float3 pab = float3(0.0f, 0.0f, 1.0f); + float3 pbc_ = float3(scospin, 0.0f, 0.5f); + float3 pca_ = float3(0.0f, scospin, cospin); + + PlatonicCtx ctx; + ctx.g_rot = pm_buildRot(normalize(r0), normalize(r1)); + ctx.g_gd = float2(1e3f); + ctx.poly_nc = nc; + ctx.poly_pab = pab; + ctx.poly_pbc = normalize(pbc_); + ctx.poly_pca = normalize(pca_); + ctx.poly_p = normalize(POLY_U*pab + POLY_V*pbc_ + POLY_W*pca_); + + float sphereEntry; + float sphereExit; + float primaryMaxRayLen = 8.0f; + if (pm_sphereHit(ro, rd, PM_BOUND_RADIUS, sphereEntry, sphereExit)) { + primaryMaxRayLen = max(sphereExit + 0.35f, 1.5f); + } + float3 col = pm_render3(ro, rd, primaryMaxRayLen, ctx); + + // ACES filmic tone-mapping + gamma + col = pm_aces(col); + col = sqrt(max(col, 0.0f)); + return float4(col, 1.0f); +} diff --git a/vr-dive/Demos/PlatonicMirror/PlatonicMirrorTypes.swift b/vr-dive/Demos/PlatonicMirror/PlatonicMirrorTypes.swift new file mode 100644 index 0000000..3a09185 --- /dev/null +++ b/vr-dive/Demos/PlatonicMirror/PlatonicMirrorTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct PlatonicMirrorUniforms in PlatonicMirrorShaders.metal. +struct PlatonicMirrorUniforms { + var time: Float + var viewCount: UInt32 + var solidScale: Float // world metres per local unit + var _pad: Float + var objectCenter: SIMD4 // xyz = world position of solid centre +} diff --git a/vr-dive/Demos/PlayingMarble/PlayingMarbleRenderer.swift b/vr-dive/Demos/PlayingMarble/PlayingMarbleRenderer.swift new file mode 100644 index 0000000..5760122 --- /dev/null +++ b/vr-dive/Demos/PlayingMarble/PlayingMarbleRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// PlayingMarbleRenderer.swift +// +// Cube-container adaptation of ShaderToy "Playing marble" (MtX3Ws). +// 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 PlayingMarbleRenderer: VisualPatternController { + let identifier: VisualPatternKind = .playingMarble + 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 = PlayingMarbleRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try PlayingMarbleRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = PlayingMarbleRenderer.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 = PlayingMarbleUniforms( + 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 PlayingMarbleRenderer { + 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: "playingMarbleVertex") + desc.fragmentFunction = library.makeFunction(name: "playingMarbleFragment") + 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/PlayingMarble/PlayingMarbleShaders.metal b/vr-dive/Demos/PlayingMarble/PlayingMarbleShaders.metal new file mode 100644 index 0000000..108c0a0 --- /dev/null +++ b/vr-dive/Demos/PlayingMarble/PlayingMarbleShaders.metal @@ -0,0 +1,203 @@ +// PlayingMarbleShaders.metal +// "Playing marble" — cube-container adaptation of ShaderToy "MtX3Ws" +// Source: https://www.shadertoy.com/view/MtX3Ws +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Source notes: +// - The original shader ray-marches a glowing fractal volume inside a glassy +// sphere and reflects an environment map on the outer shell. +// - This version keeps the sphere intersection, internal volumetric march and +// reflective shell behavior, but replaces the synthetic screen camera with a +// real per-eye world ray entering a 2 m cube container. +// - The marble itself lives in scene space and is not clipped by the cube. +// When the viewer is outside the cube, marching begins at the visible cube +// face. When the viewer is inside, marching begins at the eye. + +#include +using namespace metal; + +struct PlayingMarbleUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct PlayingMarbleVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float PM_SPHERE_RADIUS = 2.0f; +static constant float3 PM_BOX_HALF = float3(1.0f); + +vertex PlayingMarbleVertexOut playingMarbleVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant PlayingMarbleUniforms &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; + + PlayingMarbleVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 csqr(float2 a) { + return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y); +} + +static float2 pmRotate(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 pmRotateScene(float3 p, float time) { + p.yz = pmRotate(p.yz, time * 0.23f); + p.xz = pmRotate(p.xz, time * 0.19f + 0.35f); + return p; +} + +static float2 sphereIntersect(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 pmMap(float3 p) { + float res = 0.0f; + float3 c = p; + for (int i = 0; i < 10; ++i) { + p = 0.7f * abs(p) / max(dot(p, p), 1.0e-4f) - 0.7f; + p.yz = csqr(p.yz); + p = p.zxy; + res += exp(-19.0f * abs(dot(p, c))); + } + return res * 0.5f; +} + +static float3 pmEnvironment(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), 5.0f); + float sun = pow(max(dot(dir, normalize(float3(0.35f, 0.45f, -0.82f))), 0.0f), 64.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x + dir.z) * 14.0f + time * 0.3f); + float3 sky = mix(float3(0.02f, 0.03f, 0.05f), float3(0.15f, 0.21f, 0.3f), skyMix); + sky += float3(0.1f, 0.18f, 0.28f) * horizon * shimmer * 0.35f; + sky += float3(1.0f, 0.92f, 0.8f) * sun; + return clamp(sky, 0.0f, 2.5f); +} + +static float2 pmBoxIntersect(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 pmFaceUV(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 raymarch(float3 ro, float3 rd, float2 tminmax) { + float t = tminmax.x; + const float dt = 0.02f; + float3 col = float3(0.0f); + float c = 0.0f; + for (int i = 0; i < 64; ++i) { + t += dt * exp(-2.0f * c); + if (t > tminmax.y) { + break; + } + + c = pmMap(ro + t * rd); + col = 0.99f * col + 0.08f * float3(c * c, c, c * c * c); + } + return col; +} + +fragment float4 playingMarbleFragment( + PlayingMarbleVertexOut in [[stage_in]], + constant PlayingMarbleUniforms &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) < PM_BOX_HALF - 1.0e-3f); + float2 tOuter = pmBoxIntersect(eye, rd, PM_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + const float sceneScale = 2.2f; + float3 ro = (eye + rd * (tStart + 0.001f)) * sceneScale; + float3 marchDir = normalize(rd); + ro = pmRotateScene(ro, uniforms.time); + marchDir = normalize(pmRotateScene(marchDir, uniforms.time)); + + float2 tmm = sphereIntersect(ro, marchDir, float4(0.0f, 0.0f, 0.0f, PM_SPHERE_RADIUS)); + float3 col = float3(0.0f); + + if (tmm.x < 0.0f && tmm.y < 0.0f) { + col = pmEnvironment(marchDir, uniforms.time); + } else { + float tNear = max(tmm.x, 0.0f); + float tFar = max(tmm.y, tNear); + col = raymarch(ro, marchDir, float2(tNear, tFar)); + + float tSurface = (tmm.x > 0.0f) ? tmm.x : tmm.y; + float3 hitPos = ro + tSurface * marchDir; + float3 nor = hitPos / PM_SPHERE_RADIUS; + float3 reflected = reflect(marchDir, nor); + float fre = pow(0.5f + clamp(dot(reflected, marchDir), 0.0f, 1.0f), 3.0f) * 1.3f; + col += pmEnvironment(reflected, uniforms.time) * fre; + } + + float2 faceUV = pmFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.2f * dot(faceUV, faceUV); + col = 0.5f * log(1.0f + col); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/PlayingMarble/PlayingMarbleTypes.swift b/vr-dive/Demos/PlayingMarble/PlayingMarbleTypes.swift new file mode 100644 index 0000000..784d013 --- /dev/null +++ b/vr-dive/Demos/PlayingMarble/PlayingMarbleTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct PlayingMarbleUniforms in PlayingMarbleShaders.metal. +struct PlayingMarbleUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombRenderer.swift b/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombRenderer.swift new file mode 100644 index 0000000..cb09a2d --- /dev/null +++ b/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// PoincareBallHoneycombRenderer.swift +// Exploratory proof-of-concept for a 3D Poincare-ball honeycomb. +// The renderer reuses the standard 2 m cube container and drives a fragment +// shader that folds points by a hyperbolic Coxeter mirror set inside the ball. + +final class PoincareBallHoneycombRenderer: VisualPatternController { + let identifier: VisualPatternKind = .poincareBallHoneycomb + 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.02, -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 = PoincareBallHoneycombRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try PoincareBallHoneycombRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = PoincareBallHoneycombRenderer.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 = PoincareBallHoneycombUniforms( + 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 PoincareBallHoneycombRenderer { + 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: "poincareBallHoneycombVertex") + desc.fragmentFunction = library.makeFunction(name: "poincareBallHoneycombFragment") + 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/PoincareBallHoneycomb/PoincareBallHoneycombShaders.metal b/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombShaders.metal new file mode 100644 index 0000000..c98bc01 --- /dev/null +++ b/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombShaders.metal @@ -0,0 +1,308 @@ +// PoincareBallHoneycombShaders.metal +// Exploratory proof-of-concept for a Poincare-ball honeycomb. +// +// Source note: +// - This shader is built from the Coxeter mirror construction already used in +// HyperbolicGroupLimitSetShaders.metal, but adapted here to fold interior +// points of the ball instead of only boundary samples. +// - The mirror set uses the hyperbolic tetrahedral Coxeter data [3,3,7] as a +// compact demonstrator for 3D hyperbolic tessellation structure. + +#include +using namespace metal; + +struct PoincareBallHoneycombUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct PoincareBallHoneycombVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct PBHMirrors { + float3 A; + float3 B; + float3 D; + float4 C; +}; + +struct PBHFoldResult { + float3 point; + float4 distances; + float orb; + int count; + uint converged; +}; + +static constant float PBH_PI = 3.141592653f; +static constant float PBH_TAU = 6.283185307f; +static constant float3 PBH_BOX_HALF = float3(1.0f); +static constant float PBH_MODEL_SCALE = 0.9f; +static constant float3 PBH_PQR = float3(3.0f, 3.0f, 7.0f); +static constant int PBH_MAX_FOLDS = 48; +static constant int PBH_VOLUME_STEPS = 72; + +vertex PoincareBallHoneycombVertexOut poincareBallHoneycombVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant PoincareBallHoneycombUniforms &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; + + PoincareBallHoneycombVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float pbhDihedral(float x) { + return cos(PBH_PI / x); +} + +static float3 pbhRotateX(float3 p, float a) { + float c = cos(a); + float s = sin(a); + return float3(p.x, c * p.y - s * p.z, s * p.y + c * p.z); +} + +static float3 pbhRotateY(float3 p, float a) { + float c = cos(a); + float s = sin(a); + return float3(c * p.x + s * p.z, p.y, -s * p.x + c * p.z); +} + +static float3 pbhRotateScene(float3 p, float time) { + p = pbhRotateX(p, 0.72f); + p = pbhRotateY(p, time * 0.08f + 0.22f); + return p; +} + +static PBHMirrors pbhSetupMirrors() { + float cp = pbhDihedral(PBH_PQR.x); + float sp = sqrt(max(1.0f - cp * cp, 0.0f)); + float cq = pbhDihedral(PBH_PQR.y); + float cr = pbhDihedral(PBH_PQR.z); + + PBHMirrors mirrors; + mirrors.A = float3(0.0f, 0.0f, 1.0f); + mirrors.B = float3(0.0f, sp, -cp); + mirrors.D = float3(1.0f, 0.0f, 0.0f); + + float r = 1.0f / cr; + float k = r * cq / sp; + float3 cen = float3(1.0f, k, 0.0f); + mirrors.C = float4(cen, r) / sqrt(dot(cen, cen) - r * r); + return mirrors; +} + +static float2 pbhBoxIntersect(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 pbhSphereIntersect(float3 ro, float3 rd, float radius) { + float b = dot(ro, rd); + float c = dot(ro, ro) - radius * radius; + float h = b * b - c; + if (h < 0.0f) { + return float2(-1.0f, -1.0f); + } + h = sqrt(h); + return float2(-b - h, -b + h); +} + +static bool pbhReflectPlane(thread float3 &p, float3 n, thread int &count) { + float k = dot(p, n); + if (k >= 0.0f) { + return false; + } + p -= 2.0f * k * n; + count += 1; + return true; +} + +static bool pbhReflectSphere(thread float3 &p, float4 sphere, thread int &count, thread float &orb) { + float3 q = p - sphere.xyz; + float d2 = dot(q, q); + float r2 = sphere.w * sphere.w; + if (d2 >= r2 || d2 <= 1.0e-6f) { + return false; + } + float k = r2 / d2; + p = sphere.xyz + q * k; + orb *= k; + count += 1; + return true; +} + +static float4 pbhMirrorDistances(float3 p, PBHMirrors mirrors) { + float dA = abs(dot(p, mirrors.A)); + float dB = abs(dot(p, mirrors.B)); + float dD = abs(dot(p, mirrors.D)); + float dC = abs(length(p - mirrors.C.xyz) - mirrors.C.w); + return float4(dA, dB, dC, dD); +} + +static float2 pbhTwoSmallest(float4 d) { + float4 s = d; + if (s.x > s.y) { float t = s.x; s.x = s.y; s.y = t; } + if (s.z > s.w) { float t = s.z; s.z = s.w; s.w = t; } + if (s.x > s.z) { float t = s.x; s.x = s.z; s.z = t; } + if (s.y > s.w) { float t = s.y; s.y = s.w; s.w = t; } + if (s.y > s.z) { float t = s.y; s.y = s.z; s.z = t; } + return s.xy; +} + +static PBHFoldResult pbhFoldPoint(float3 p, PBHMirrors mirrors) { + PBHFoldResult result; + result.point = p; + result.orb = 1.0f; + result.count = 0; + result.converged = 0u; + + for (int iter = 0; iter < PBH_MAX_FOLDS; ++iter) { + bool changed = false; + changed = pbhReflectPlane(result.point, mirrors.A, result.count) || changed; + changed = pbhReflectPlane(result.point, mirrors.B, result.count) || changed; + changed = pbhReflectSphere(result.point, mirrors.C, result.count, result.orb) || changed; + changed = pbhReflectPlane(result.point, mirrors.D, result.count) || changed; + + float len2 = dot(result.point, result.point); + if (len2 > 0.9998f * 0.9998f) { + result.point *= 0.9998f * rsqrt(max(len2, 1.0e-6f)); + } + + if (!changed) { + result.converged = 1u; + break; + } + } + + result.distances = pbhMirrorDistances(result.point, mirrors); + return result; +} + +static float3 pbhPalette(float t) { + return 0.55f + 0.45f * cos(PBH_TAU * (t + float3(0.0f, 0.13f, 0.31f))); +} + +static float3 pbhBackground(float3 rd) { + float sky = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float horizon = pow(max(1.0f - abs(rd.y), 0.0f), 5.0f); + float3 bg = mix(float3(0.01f, 0.014f, 0.024f), float3(0.045f, 0.065f, 0.11f), sky); + bg += horizon * float3(0.035f, 0.055f, 0.095f); + return bg; +} + +fragment float4 poincareBallHoneycombFragment( + PoincareBallHoneycombVertexOut in [[stage_in]], + constant PoincareBallHoneycombUniforms &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 = max(uniforms.cubeScale, 1.0e-4f); + float3 eye = (camWorld - center) / scale; + float3 surfacePos = (in.worldPos - center) / scale; + float3 rdLocal = normalize(surfacePos - eye); + + bool insideCube = all(abs(eye) < PBH_BOX_HALF - 1.0e-3f); + float2 tCube = pbhBoxIntersect(eye, rdLocal, PBH_BOX_HALF); + if (!insideCube && tCube.x > tCube.y) { + discard_fragment(); + } + + float tStart = insideCube ? 0.0f : max(tCube.x, 0.0f); + float3 localOrigin = eye + rdLocal * (tStart + 0.001f); + + float3 ro = pbhRotateScene(localOrigin / PBH_MODEL_SCALE, uniforms.time); + float3 rd = normalize(pbhRotateScene(rdLocal, uniforms.time)); + + float2 tBall = pbhSphereIntersect(ro, rd, 1.0f); + float3 bg = pbhBackground(rd); + bool insideBall = dot(ro, ro) < 0.999f; + if ((!insideBall && tBall.y <= 0.0f) || tBall.x > tBall.y) { + return float4(bg, 1.0f); + } + + float tBallStart = insideBall ? 0.0f : max(tBall.x, 0.0f); + float tBallEnd = tBall.y; + if (tBallEnd <= tBallStart) { + return float4(bg, 1.0f); + } + + float shellOverlay = 0.0f; + float3 shellColor = float3(0.0f); + if (!insideBall) { + float3 shellPos = ro + rd * tBallStart; + float3 shellNormal = normalize(shellPos); + float fresnel = pow(max(1.0f - dot(-rd, shellNormal), 0.0f), 4.0f); + shellColor = mix(float3(0.05f, 0.10f, 0.18f), float3(0.20f, 0.38f, 0.62f), 0.35f + 0.65f * fresnel); + shellOverlay = 0.12f + 0.18f * fresnel; + } + + PBHMirrors mirrors = pbhSetupMirrors(); + float stepSize = (tBallEnd - tBallStart) / float(PBH_VOLUME_STEPS); + float transmittance = 1.0f; + float3 volumeColor = float3(0.0f); + + for (int stepIndex = 0; stepIndex < PBH_VOLUME_STEPS; ++stepIndex) { + float t = tBallStart + (float(stepIndex) + 0.5f) * stepSize; + float3 p = ro + rd * t; + + PBHFoldResult folded = pbhFoldPoint(p, mirrors); + float2 nearest = pbhTwoSmallest(folded.distances); + float faceField = nearest.x; + float edgeField = length(nearest); + float interiorDepth = clamp(1.0f - length(p), 0.0f, 1.0f); + float boundaryFade = smoothstep(0.018f, 0.13f, interiorDepth); + float faceDensity = exp(-52.0f * faceField); + float edgeDensity = exp(-96.0f * edgeField); + float shellDensity = exp(-120.0f * abs(length(p) - 1.0f)); + + float hueParam = 0.025f * float(folded.count) + 0.045f * log(max(folded.orb, 1.0e-4f)); + float3 tint = mix(float3(0.08f, 0.26f, 0.78f), pbhPalette(hueParam), 0.78f); + tint = mix(tint, float3(1.0f, 0.93f, 0.82f), clamp(edgeDensity * 0.35f, 0.0f, 1.0f)); + + float density = boundaryFade * (0.020f * faceDensity + 0.065f * edgeDensity); + float shellAmount = 0.018f * shellDensity; + + volumeColor += transmittance * tint * density * stepSize * 15.0f; + volumeColor += transmittance * float3(0.09f, 0.18f, 0.30f) * shellAmount * stepSize * 8.0f; + transmittance *= exp(-(density + shellAmount) * stepSize * 11.0f); + if (transmittance < 0.02f) { + break; + } + } + + float3 col = bg * transmittance + volumeColor; + col += shellColor * shellOverlay; + col = 1.0f - exp(-1.45f * max(col, 0.0f)); + col = sqrt(max(col, 0.0f)); + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombTypes.swift b/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombTypes.swift new file mode 100644 index 0000000..7559845 --- /dev/null +++ b/vr-dive/Demos/PoincareBallHoneycomb/PoincareBallHoneycombTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct PoincareBallHoneycombUniforms in +/// PoincareBallHoneycombShaders.metal. +struct PoincareBallHoneycombUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/PongWar/PongWarShaders.metal b/vr-dive/Demos/PongWar/PongWarShaders.metal index e9ac02f..10a51e8 100644 --- a/vr-dive/Demos/PongWar/PongWarShaders.metal +++ b/vr-dive/Demos/PongWar/PongWarShaders.metal @@ -63,7 +63,7 @@ vertex PongWarVertexOut pongWarVertexShader( if (isSphere < 0.5) { // 立方体:根据边界掩码判断这条棱是否需要显示 - float nearness = state.edgeData.x; // 小球接近度 (0=远, 1=近) + // float nearness = state.edgeData.x; // 小球接近度 (0=远, 1=近) float boundaryMask = state.edgeData.y; // 6个面的颜色边界状态 float outerMask = state.edgeData.z; // 6个面的外边界状态(动态) int bmask = int(boundaryMask); diff --git a/vr-dive/Demos/QuatPolynomial/QuatPolynomialRenderer.swift b/vr-dive/Demos/QuatPolynomial/QuatPolynomialRenderer.swift new file mode 100644 index 0000000..193064c --- /dev/null +++ b/vr-dive/Demos/QuatPolynomial/QuatPolynomialRenderer.swift @@ -0,0 +1,157 @@ +import Metal +import simd + +final class QuatPolynomialRenderer: VisualPatternController { + let identifier: VisualPatternKind = .quatPolynomial + let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + + private let renderPipelineState: MTLRenderPipelineState + private let computePipelineState: MTLComputePipelineState + private let depthStencilState: MTLDepthStencilState + private let meshVertexBuffer: MTLBuffer + private let meshIndexBuffer: MTLBuffer + private let meshIndexCount: Int + private let particleStateBuffer: MTLBuffer + private let particleCount: Int + private let maxViewCount: Int + + // Grid layout — must match the #define constants in the metal file. + private static let theta1Grid = 64 + private static let theta2Grid = 64 + private static let polyDegree = 5 + private static let circlePoints = 20 + // Total: 64 × 64 × 5 × 20 = 409 600 particles + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + self.particleCount = Self.theta1Grid * Self.theta2Grid * Self.polyDegree * Self.circlePoints + + renderPipelineState = try QuatPolynomialRenderer.makeRenderPipeline( + device: device, library: library, maxViewCount: self.maxViewCount) + computePipelineState = try QuatPolynomialRenderer.makeComputePipeline( + device: device, library: library) + depthStencilState = QuatPolynomialRenderer.makeDepthStencilState(device: device) + + let geo = MeshGeometryFactory.makeOctahedron(device: device) + meshVertexBuffer = geo.vertexBuffer + meshIndexBuffer = geo.indexBuffer + meshIndexCount = geo.indexCount + + particleStateBuffer = device.makeBuffer( + length: MemoryLayout.stride * particleCount, + options: .storageModeShared)! + } + + func resetToInitialState() {} + + func updateSimulation(_ context: PatternSimulationContext) { + guard !context.isPaused else { return } + + var uniforms = QuatPolynomialUniforms( + time: context.time, + speed: context.speedMultiplier * 0.15, + worldScale: 0.22, + particleCount: UInt32(particleCount) + ) + + guard let commandBuffer = context.commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeComputeCommandEncoder() + else { return } + + encoder.setComputePipelineState(computePipelineState) + encoder.setBuffer(particleStateBuffer, offset: 0, index: 0) + encoder.setBytes(&uniforms, length: MemoryLayout.stride, index: 1) + + let threadsPerGroup = min(computePipelineState.maxTotalThreadsPerThreadgroup, 128) + let threadgroups = MTLSize( + width: (particleCount + threadsPerGroup - 1) / threadsPerGroup, + height: 1, depth: 1) + encoder.dispatchThreadgroups( + threadgroups, + threadsPerThreadgroup: MTLSize(width: threadsPerGroup, height: 1, depth: 1)) + encoder.endEncoding() + commandBuffer.commit() + commandBuffer.waitUntilCompleted() + } + + func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) { + encoder.setRenderPipelineState(renderPipelineState) + encoder.setDepthStencilState(depthStencilState) + encoder.setCullMode(.none) + encoder.setFrontFacing(.counterClockwise) + + encoder.setVertexBuffer(meshVertexBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(particleStateBuffer, offset: 0, index: 1) + + context.applyViewConfiguration(on: encoder) + + var sceneUniforms = SceneUniforms( + time: context.time, + layerCount: UInt32(context.viewData.viewCount)) + encoder.setVertexBytes( + &sceneUniforms, length: MemoryLayout.stride, index: 2) + + 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: 3) + } + } + + encoder.setFragmentBytes( + &sceneUniforms, length: MemoryLayout.stride, index: 0) + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: meshIndexCount, + indexType: .uint16, + indexBuffer: meshIndexBuffer, + indexBufferOffset: 0, + instanceCount: particleCount + ) + } +} + +extension QuatPolynomialRenderer { + fileprivate static func makeRenderPipeline( + device: MTLDevice, + library: MTLLibrary, + maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "quatPolyVertexShader") + descriptor.fragmentFunction = library.makeFunction(name: "quatPolyFragmentShader") + descriptor.colorAttachments[0].pixelFormat = .rgba16Float + descriptor.depthAttachmentPixelFormat = .depth32Float + descriptor.inputPrimitiveTopology = .triangle + + 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 + descriptor.vertexDescriptor = vd + + descriptor.maxVertexAmplificationCount = max(maxViewCount, 1) + return try device.makeRenderPipelineState(descriptor: descriptor) + } + + fileprivate static func makeComputePipeline( + device: MTLDevice, + library: MTLLibrary + ) throws -> MTLComputePipelineState { + let fn = library.makeFunction(name: "computeQuatPolyParticles")! + return try device.makeComputePipelineState(function: fn) + } + + fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState { + let desc = MTLDepthStencilDescriptor() + desc.depthCompareFunction = .greater // reverse-Z + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} diff --git a/vr-dive/Demos/QuatPolynomial/QuatPolynomialShaders.metal b/vr-dive/Demos/QuatPolynomial/QuatPolynomialShaders.metal new file mode 100644 index 0000000..3db7562 --- /dev/null +++ b/vr-dive/Demos/QuatPolynomial/QuatPolynomialShaders.metal @@ -0,0 +1,194 @@ +#include +using namespace metal; + +struct SceneUniforms { + float time; + uint layerCount; + float2 padding; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct QuatPolynomialParticleState { + float4 positionAndScale; + float4 color; +}; + +struct QuatPolynomialUniforms { + float time; + float speed; + float worldScale; + uint particleCount; +}; + +// ── Complex arithmetic ──────────────────────────────────────────────────────── +inline float2 cmul(float2 a, float2 b) { + return float2(a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x); +} +inline float2 cdiv(float2 a, float2 b) { + float d = max(dot(b, b), 1e-24f); + return float2(a.x*b.x + a.y*b.y, a.y*b.x - a.x*b.y) / d; +} +inline float2 cpow3(float2 c) { + float2 c2 = cmul(c, c); + return cmul(c2, c); +} + +// f(x) = x^5 + a2*x^2 + a1*x + a0 +inline float2 evalPoly(float2 x, float2 a2, float2 a1, float2 a0) { + float2 x2 = cmul(x, x); + float2 x3 = cmul(x2, x); + float2 x5 = cmul(x3, x2); + return x5 + cmul(a2, x2) + cmul(a1, x) + a0; +} + +// ── HSV → RGB ───────────────────────────────────────────────────────────────── +inline float3 hsv2rgb(float h, float s, float v) { + float h6 = fract(h) * 6.0f; + float f = fract(h6); + float p = v * (1.0f - s); + float q = v * (1.0f - s * f); + float t = v * (1.0f - s * (1.0f - f)); + int i = int(h6); + if (i == 0) return float3(v, t, p); + if (i == 1) return float3(q, v, p); + if (i == 2) return float3(p, v, t); + if (i == 3) return float3(p, q, v); + if (i == 4) return float3(t, p, v); + return float3(v, p, q); +} + +// ── Compute shader ──────────────────────────────────────────────────────────── +// Each particle encodes a specific (theta1, theta2, root, circle_point) tuple. +// The polynomial f(x) = x^5 + t1^3*x^2 + t2^2*x + 1 has 5 complex roots. +// Each complex root α+βi generates a circle in quaternion space: +// (α, β·cos φ, β·sin φ) for φ ∈ [0, 2π] +// sweeping (θ1, θ2) ∈ S¹×S¹ traces the full quaternion root variety in 3D. + +#define THETA1_GRID 64 +#define THETA2_GRID 64 +#define POLY_DEGREE 5 +#define CIRCLE_PTS 20 + +kernel void computeQuatPolyParticles( + device QuatPolynomialParticleState *states [[buffer(0)]], + constant QuatPolynomialUniforms &uniforms [[buffer(1)]], + uint id [[thread_position_in_grid]]) +{ + if (id >= uniforms.particleCount) return; + + // ── Decode (θ1, θ2, root, φ) from flat particle id ────────────────────── + uint phi_idx = id % uint(CIRCLE_PTS); + uint rem = id / uint(CIRCLE_PTS); + uint root_idx = rem % uint(POLY_DEGREE); + rem = rem / uint(POLY_DEGREE); + uint theta2_idx = rem % uint(THETA2_GRID); + uint theta1_idx = rem / uint(THETA2_GRID); + + // ── Parameter angles, slowly rotating over time ────────────────────────── + float s = uniforms.speed * uniforms.time; + float theta1 = (float(theta1_idx) / float(THETA1_GRID)) * 2.0f * M_PI_F + s; + float theta2 = (float(theta2_idx) / float(THETA2_GRID)) * 2.0f * M_PI_F + + s * 0.618033988f; // golden-ratio rate avoids resonance + + // ── Polynomial coefficients: f(x) = x^5 + t1³x² + t2²x + 1 ───────────── + float2 t1 = float2(cos(theta1), sin(theta1)); + float2 t2 = float2(cos(theta2), sin(theta2)); + float2 a2 = cpow3(t1); // t1^3 — 3-fold θ1 symmetry + float2 a1 = cmul(t2, t2); // t2^2 — 2-fold θ2 symmetry + float2 a0 = float2(1.0f, 0.0f); + + // ── Durand-Kerner / Weierstrass simultaneous root finding ──────────────── + // Initial guesses: equally spaced on unit circle with slight offset. + float2 roots[POLY_DEGREE]; + float2 new_roots[POLY_DEGREE]; + for (int k = 0; k < POLY_DEGREE; k++) { + float angle = (2.0f * M_PI_F * float(k) + 0.31f) / float(POLY_DEGREE); + roots[k] = float2(1.1f * cos(angle), 1.1f * sin(angle)); + } + + for (int iter = 0; iter < 12; iter++) { + for (int k = 0; k < POLY_DEGREE; k++) { + float2 fk = evalPoly(roots[k], a2, a1, a0); + float2 denom = float2(1.0f, 0.0f); + for (int j = 0; j < POLY_DEGREE; j++) { + if (j != k) denom = cmul(denom, roots[k] - roots[j]); + } + float dlen2 = dot(denom, denom); + new_roots[k] = (dlen2 > 1e-20f) ? roots[k] - cdiv(fk, denom) : roots[k]; + } + for (int k = 0; k < POLY_DEGREE; k++) roots[k] = new_roots[k]; + } + + // ── Quaternion circle ───────────────────────────────────────────────────── + float2 root = roots[root_idx]; + float alpha = root.x; // real part → x coordinate + float beta = root.y; // imag part → circle radius in yz plane + + // Validity: discard unconverged or blown-up roots + float rootMag = length(root); + float residual = length(evalPoly(root, a2, a1, a0)); + bool valid = (rootMag < 3.5f) && (residual < 0.06f) + && !isnan(root.x) && !isnan(root.y); + + float phi = 2.0f * M_PI_F * float(phi_idx) / float(CIRCLE_PTS); + float3 lp = float3(alpha, beta * cos(phi), beta * sin(phi)); + float3 wp = lp * uniforms.worldScale + float3(0.0f, -0.1f, -1.2f); + float scale = valid ? 0.10f : 0.0f; + + // ── Colour: hue from θ1, shifted per root sheet ─────────────────────────── + float hue = float(theta1_idx) / float(THETA1_GRID) + + float(root_idx) / float(POLY_DEGREE); + float sat = 0.80f; + float val = 0.70f + 0.30f * abs(sin(float(theta2_idx) / float(THETA2_GRID) * M_PI_F)); + float3 rgb = hsv2rgb(hue, sat, val); + + states[id].positionAndScale = float4(wp, scale); + states[id].color = float4(rgb, 1.0f); +} + +// ── Vertex / fragment shaders ───────────────────────────────────────────────── +struct VertexOut { + float4 clipPos [[position]]; + float3 normal [[flat]]; + float4 particleColor [[flat]]; +}; + +vertex VertexOut quatPolyVertexShader( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + const device QuatPolynomialParticleState *states [[buffer(1)]], + constant SceneUniforms &uniforms [[buffer(2)]], + constant float4x4 *vpMatrices [[buffer(3)]], + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]]) +{ + uint layers = max(uniforms.layerCount, 1u); + uint viewIndex = min((uint)amplificationID, layers - 1u); + + QuatPolynomialParticleState state = states[instanceID]; + MeshVertex vtx = vertices[vertexID]; + + float3 center = state.positionAndScale.xyz; + float scale = state.positionAndScale.w; + float3 pos = center + vtx.position * scale; + + VertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(pos, 1.0f); + out.normal = vtx.normal; + out.particleColor = state.color; + return out; +} + +fragment float4 quatPolyFragmentShader( + VertexOut in [[stage_in]], + constant SceneUniforms &uniforms [[buffer(0)]]) +{ + float3 n = normalize(in.normal); + float3 lightDir = normalize(float3(-0.2f, 0.8f, -0.4f)); + float ndotl = max(dot(n, lightDir), 0.0f) * 0.65f + 0.35f; + return float4(in.particleColor.rgb * ndotl, 1.0f); +} diff --git a/vr-dive/Demos/QuatPolynomial/QuatPolynomialTypes.swift b/vr-dive/Demos/QuatPolynomial/QuatPolynomialTypes.swift new file mode 100644 index 0000000..36a1d77 --- /dev/null +++ b/vr-dive/Demos/QuatPolynomial/QuatPolynomialTypes.swift @@ -0,0 +1,13 @@ +import simd + +struct QuatPolynomialParticleState { + var positionAndScale: SIMD4 // xyz = world position, w = visual scale + var color: SIMD4 +} + +struct QuatPolynomialUniforms { + var time: Float + var speed: Float + var worldScale: Float + var particleCount: UInt32 +} diff --git a/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoRenderer.swift b/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoRenderer.swift new file mode 100644 index 0000000..309b057 --- /dev/null +++ b/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoRenderer.swift @@ -0,0 +1,599 @@ +import Metal +import simd + +private struct RMDVertex { + var position: SIMD3 + var normal: SIMD3 + var color: SIMD3 +} + +private struct RMDGeometry { + var vertices: [RMDVertex] = [] + var indices: [UInt32] = [] + + mutating func append(vertices newVertices: [RMDVertex], indices newIndices: [UInt32]) { + let base = UInt32(vertices.count) + vertices += newVertices + indices += newIndices.map { $0 + base } + } +} + +private struct RMDMeshBuffers { + var vertexBuffer: MTLBuffer + var indexBuffer: MTLBuffer + var indexCount: Int +} + +private struct RMDTraceStep { + var distanceAlongRay: Float + var radius: Float +} + +private struct RMDTraceResult { + var steps: [RMDTraceStep] + var hitDistance: Float +} + +private let rmdTraceEpsilon: Float = 0.003 + +private func rmdBasis(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 rmdAppendSphere( + _ geometry: inout RMDGeometry, + center: SIMD3, + radius: Float, + color: SIMD3, + latSegments: Int = 10, + lonSegments: Int = 20 +) { + var vertices: [RMDVertex] = [] + 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(RMDVertex(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) = rmdBasis(for: tangent) + + var vertices: [RMDVertex] = [] + 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(RMDVertex(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) = rmdBasis(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 + rmdAppendCylinder( + &geometry, from: lastPoint, to: point, radius: tubeRadius, color: color, radialSegments: 8) + lastPoint = point + } +} + +private func rmdAppendWireSphere( + _ geometry: inout RMDGeometry, + center: SIMD3, + radius: Float, + wireRadius: Float, + color: SIMD3, + guideDirection: SIMD3 +) { + let ray = simd_normalize(guideDirection) + let (u, v) = rmdBasis(for: ray) + rmdAppendCircle( + &geometry, center: center, axis: u, radius: radius, tubeRadius: wireRadius, color: color) + rmdAppendCircle( + &geometry, center: center, axis: v, radius: radius, tubeRadius: wireRadius, color: color) + rmdAppendCircle( + &geometry, center: center, axis: ray, radius: radius, tubeRadius: wireRadius, color: color) +} + +private func rmdAppendTorus( + _ geometry: inout RMDGeometry, + center: SIMD3, + majorRadius: Float, + minorRadius: Float, + color: SIMD3, + ringSegments: Int = 28, + tubeSegments: Int = 14 +) { + var vertices: [RMDVertex] = [] + 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(RMDVertex(position: position, normal: normal, color: color)) + } + } + + let ringStride = 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 { + rmdAppendCylinder(&geometry, from: corners[a], to: corners[b], radius: radius, color: color) + } +} + +private func rmdAppendRectFrame( + _ geometry: inout RMDGeometry, + center: SIMD3, + halfWidth: Float, + halfHeight: Float, + radius: Float, + color: SIMD3 +) { + let tl = center + SIMD3(0, halfHeight, -halfWidth) + let tr = center + SIMD3(0, halfHeight, halfWidth) + let bl = center + SIMD3(0, -halfHeight, -halfWidth) + let br = center + SIMD3(0, -halfHeight, halfWidth) + rmdAppendCylinder(&geometry, from: tl, to: tr, radius: radius, color: color) + rmdAppendCylinder(&geometry, from: tr, to: br, radius: radius, color: color) + rmdAppendCylinder(&geometry, from: br, to: bl, radius: radius, color: color) + rmdAppendCylinder(&geometry, from: bl, to: tl, radius: radius, color: color) +} + +private func rmdSdSphere(_ p: SIMD3, center: SIMD3, radius: Float) -> Float { + simd_length(p - center) - radius +} + +private func rmdSdTorus( + _ 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 rmdSceneSdf( + _ p: SIMD3, + sphereCenter: SIMD3, + sphereRadius: Float, + torusCenter: SIMD3, + torusMajorRadius: Float, + torusMinorRadius: Float +) -> Float { + min( + rmdSdSphere(p, center: sphereCenter, radius: sphereRadius), + rmdSdTorus(p, center: torusCenter, majorRadius: torusMajorRadius, minorRadius: torusMinorRadius) + ) +} + +private func rmdTrace( + from start: SIMD3, + rayDir: SIMD3, + maxDistance: Float, + maxSteps: Int, + distance: (SIMD3) -> Float +) -> RMDTraceResult { + var t: Float = 0 + var steps: [RMDTraceStep] = [] + steps.reserveCapacity(maxSteps) + for _ in 0..= maxDistance { break } + } + return RMDTraceResult(steps: steps, hitDistance: min(t, maxDistance)) +} + +private func rmdRefineHitDistance( + 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 rmdProbeColor(_ base: SIMD3, stepIndex: Int) -> SIMD3 { + if stepIndex.isMultiple(of: 2) { return base } + return simd_clamp( + base * 0.45 + SIMD3(base.z, base.x, base.y) * 0.35 + SIMD3(0.20, 0.20, 0.20), + .zero, + SIMD3(repeating: 1.0) + ) +} + +private func rmdDimmedProbeColor() -> SIMD3 { + SIMD3(repeating: 0.22) +} + +private func rmdAppendProbeSpheres( + _ geometry: inout RMDGeometry, + from start: SIMD3, + rayDir: SIMD3, + steps: [RMDTraceStep], + color: SIMD3, + maxProbes: Int +) { + for (count, step) in steps.prefix(maxProbes).enumerated() { + let p = start + rayDir * step.distanceAlongRay + let probeColor = rmdProbeColor(color, stepIndex: count) + rmdAppendWireSphere( + &geometry, center: p, radius: step.radius, wireRadius: 0.00125, color: probeColor, + guideDirection: rayDir) + } +} + +private func rmdMakeMeshBuffers(device: MTLDevice, geometry: RMDGeometry) -> RMDMeshBuffers { + let vertexBuffer = device.makeBuffer( + bytes: geometry.vertices, + length: MemoryLayout.stride * geometry.vertices.count, + options: .storageModeShared + )! + let indexBuffer = device.makeBuffer( + bytes: geometry.indices, + length: MemoryLayout.stride * geometry.indices.count, + options: .storageModeShared + )! + return RMDMeshBuffers( + vertexBuffer: vertexBuffer, + indexBuffer: indexBuffer, + indexCount: geometry.indices.count) +} + +private func rmdBuildSceneGeometry(probeDimTarget: RayMarchingProbeDimTarget) -> RMDGeometry { + let lightLocal = SIMD3(-1.0, 0.3, 0.0) + let screen = SIMD3(-0.6, 0.3, 0.0) + let sphereCenter = SIMD3(0.15, 0.18, -0.58) + let sphereRadius: Float = 0.26 + let torusCenter = SIMD3(1.25, -0.14, 0.82) + let torusMajorRadius: Float = 0.31 + let torusMinorRadius: Float = 0.075 + let sphereAim = SIMD3(-0.05, -0.02, -0.55) + let torusAim = SIMD3(1.25, -0.10, 0.80) + let sphereRayDir = simd_normalize(sphereAim - lightLocal) + let torusRayDir = simd_normalize(torusAim - lightLocal) + let sceneSdf: (SIMD3) -> Float = { point in + rmdSceneSdf( + point, + sphereCenter: sphereCenter, + sphereRadius: sphereRadius, + torusCenter: torusCenter, + torusMajorRadius: torusMajorRadius, + torusMinorRadius: torusMinorRadius + ) + } + let sphereTrace = rmdTrace( + from: lightLocal, + rayDir: sphereRayDir, + maxDistance: 4.0, + maxSteps: 64, + distance: sceneSdf) + let torusTrace = rmdTrace( + from: lightLocal, + rayDir: torusRayDir, + maxDistance: 4.0, + maxSteps: 96, + distance: sceneSdf) + let sphereRayEnd = lightLocal + sphereRayDir * sphereTrace.hitDistance + let torusRayEnd = lightLocal + torusRayDir * torusTrace.hitDistance + + let cLight = SIMD3(1.0, 0.95, 0.5) + let cScreen = SIMD3(0.5, 0.65, 1.0) + let cSphere = SIMD3(0.3, 0.7, 1.0) + let cTorus = SIMD3(1.0, 0.6, 0.15) + let cDimmed = rmdDimmedProbeColor() + let cSphereProbe = + probeDimTarget == .sphere ? cDimmed : cSphere * 0.72 + SIMD3(0.28, 0.28, 0.28) + let cTorusProbe = + probeDimTarget == .torus ? cDimmed : cTorus * 0.72 + SIMD3(0.28, 0.28, 0.28) + + var geometry = RMDGeometry() + rmdAppendSphere( + &geometry, center: lightLocal, radius: 0.08, color: cLight, latSegments: 10, lonSegments: 20) + rmdAppendRectFrame( + &geometry, center: screen, halfWidth: 0.38, halfHeight: 0.26, radius: 0.015, color: cScreen) + rmdAppendSphere( + &geometry, center: sphereCenter, radius: sphereRadius, color: cSphere, latSegments: 12, + lonSegments: 24) + rmdAppendTorus( + &geometry, center: torusCenter, majorRadius: torusMajorRadius, minorRadius: torusMinorRadius, + color: cTorus) + rmdAppendCylinder( + &geometry, from: lightLocal, to: sphereRayEnd, radius: 0.003, color: cSphere, + radialSegments: 10) + rmdAppendProbeSpheres( + &geometry, from: lightLocal, rayDir: sphereRayDir, steps: sphereTrace.steps, + color: cSphereProbe, + maxProbes: sphereTrace.steps.count) + rmdAppendCylinder( + &geometry, from: lightLocal, to: torusRayEnd, radius: 0.003, color: cTorus, + radialSegments: 10) + rmdAppendProbeSpheres( + &geometry, from: lightLocal, rayDir: torusRayDir, steps: torusTrace.steps, color: cTorusProbe, + maxProbes: torusTrace.steps.count) + return geometry +} + +final class RayMarchingDemoRenderer: VisualPatternController { + let identifier: VisualPatternKind = .rayMarchingDemo + let preferredClearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + + private let pipelineState: MTLRenderPipelineState + private let depthStencilState: MTLDepthStencilState + private let meshBuffersByDimTarget: [RayMarchingProbeDimTarget: RMDMeshBuffers] + private var vertexBuffer: MTLBuffer + private var indexBuffer: MTLBuffer + private var indexCount: Int + private let maxViewCount: Int + private var activeProbeDimTarget: RayMarchingProbeDimTarget = .none + + private let objectCenter = SIMD3(0.0, 0.0, -1.9) + private let lightLocal = SIMD3(-1.0, 0.3, 0.0) + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + let noneBuffers = rmdMakeMeshBuffers( + device: device, + geometry: rmdBuildSceneGeometry(probeDimTarget: .none)) + let sphereBuffers = rmdMakeMeshBuffers( + device: device, + geometry: rmdBuildSceneGeometry(probeDimTarget: .sphere)) + let torusBuffers = rmdMakeMeshBuffers( + device: device, + geometry: rmdBuildSceneGeometry(probeDimTarget: .torus)) + meshBuffersByDimTarget = [ + .none: noneBuffers, + .sphere: sphereBuffers, + .torus: torusBuffers, + ] + vertexBuffer = noneBuffers.vertexBuffer + indexBuffer = noneBuffers.indexBuffer + indexCount = noneBuffers.indexCount + + pipelineState = try RayMarchingDemoRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = RayMarchingDemoRenderer.makeDepthStencilState(device: device) + } + + func synchronizeState(_ context: PatternSimulationContext) { + let target = context.rayMarchingProbeDimTarget + guard target != activeProbeDimTarget, let buffers = meshBuffersByDimTarget[target] else { + return + } + activeProbeDimTarget = target + vertexBuffer = buffers.vertexBuffer + indexBuffer = buffers.indexBuffer + indexCount = buffers.indexCount + } + + func updateSimulation(_ context: PatternSimulationContext) {} + func resetToInitialState() {} + + 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 = RMDMeshUniforms( + time: context.time, + viewCount: UInt32(context.viewData.viewCount), + objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0), + lightPosition: SIMD4(lightLocal.x, lightLocal.y, lightLocal.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: .uint32, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } +} + +extension RayMarchingDemoRenderer { + fileprivate static func makePipelineState( + device: MTLDevice, + library: MTLLibrary, + maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "rayMarchingDemoVertex") + desc.fragmentFunction = library.makeFunction(name: "rayMarchingDemoFragment") + 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.attributes[2].format = .float3 + vertexDescriptor.attributes[2].offset = MemoryLayout>.stride * 2 + vertexDescriptor.attributes[2].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/RayMarchingDemo/RayMarchingDemoShaders.metal b/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoShaders.metal new file mode 100644 index 0000000..825d06c --- /dev/null +++ b/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoShaders.metal @@ -0,0 +1,74 @@ +// RayMarchingDemoShaders.metal +// Standard mesh shading for CPU-generated step spheres and guide geometry. +#include +using namespace metal; + +struct RMDVertex { + float3 position; + float3 normal; + float3 color; +}; + +struct RMDUniforms { + float time; + uint viewCount; + float2 pad0; + float4 objectCenter; + float4 lightPosition; +}; + +struct RMDOut { + float4 clipPos [[position]]; + float3 worldPos; + float3 normal; + float3 color; + uint viewIndex [[flat]]; +}; + +vertex RMDOut rayMarchingDemoVertex( + ushort amplificationID [[amplification_id]], + const device RMDVertex *vertices [[buffer(0)]], + constant RMDUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + RMDVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position + uniforms.objectCenter.xyz; + + RMDOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.normal = vtx.normal; + out.color = vtx.color; + out.viewIndex = viewIndex; + return out; +} + +fragment float4 rayMarchingDemoFragment( + RMDOut in [[stage_in]], + constant RMDUniforms &uniforms [[buffer(0)]], + constant float4x4 *viewToWorld [[buffer(1)]], + bool frontFacing [[front_facing]]) +{ + 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 lightWorld = uniforms.objectCenter.xyz + uniforms.lightPosition.xyz; + + float3 N = normalize(in.normal); + if (!frontFacing) N = -N; + float3 L = normalize(lightWorld - in.worldPos); + float3 V = normalize(camWorld - in.worldPos); + float3 H = normalize(L + V); + + float diffuse = max(dot(N, L), 0.0f); + float specular = pow(max(dot(N, H), 0.0f), 40.0f); + float rim = pow(1.0f - max(dot(N, V), 0.0f), 2.5f); + float sky = 0.35f + 0.65f * (N.y * 0.5f + 0.5f); + + float3 color = in.color * (0.18f + 0.82f * diffuse) * sky; + color += in.color * rim * 0.22f; + color += float3(1.0f, 0.98f, 0.9f) * specular * 0.35f; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoTypes.swift b/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoTypes.swift new file mode 100644 index 0000000..38ee39b --- /dev/null +++ b/vr-dive/Demos/RayMarchingDemo/RayMarchingDemoTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Synced with RMDUniforms in RayMarchingDemoShaders.metal +struct RMDMeshUniforms { + var time: Float + var viewCount: UInt32 + var pad0: SIMD2 = .zero + var objectCenter: SIMD4 + var lightPosition: SIMD4 +} diff --git a/vr-dive/Demos/RecursiveLotus/RecursiveLotusRenderer.swift b/vr-dive/Demos/RecursiveLotus/RecursiveLotusRenderer.swift new file mode 100644 index 0000000..27a1962 --- /dev/null +++ b/vr-dive/Demos/RecursiveLotus/RecursiveLotusRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// RecursiveLotusRenderer.swift +// +// Cube-container adaptation of ShaderToy "Recursive Lotus" (3d2Szm). +// 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 RecursiveLotusRenderer: VisualPatternController { + let identifier: VisualPatternKind = .recursiveLotus + 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 = RecursiveLotusRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try RecursiveLotusRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = RecursiveLotusRenderer.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 = RecursiveLotusUniforms( + 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 RecursiveLotusRenderer { + 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: "recursiveLotusVertex") + desc.fragmentFunction = library.makeFunction(name: "recursiveLotusFragment") + 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/RecursiveLotus/RecursiveLotusShaders.metal b/vr-dive/Demos/RecursiveLotus/RecursiveLotusShaders.metal new file mode 100644 index 0000000..3b577a2 --- /dev/null +++ b/vr-dive/Demos/RecursiveLotus/RecursiveLotusShaders.metal @@ -0,0 +1,312 @@ +// RecursiveLotusShaders.metal +// Adapted from ShaderToy "Recursive Lotus". +// Source: https://www.shadertoy.com/view/3d2Szm +// +// Original description: +// Self-similar flower based on the log-spherical mapping. +// Accompanying blog post: https://www.osar.fr/notes/logspherical/ +// +// Metal adaptation notes: +// - The original shader constructed a synthetic orbit camera from fragCoord. +// This version reconstructs the real per-eye world ray, intersects it with a +// 2 m cube container, and starts marching at the visible cube surface or at +// the eye when the viewer is inside the cube. +// - The log-spherical tiled field is evaluated beyond the cube entry plane, so +// the simulated lotus is not clipped to the container volume. +// - GLSL globals and inout rotations are rewritten as explicit Metal helpers. + +#include +using namespace metal; + +struct RecursiveLotusUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct RecursiveLotusVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct RLParams { + float density; + float height; + float gTime; + float vcut; + float lpscale; + float cameraY; + float cameraTy; + float interpos; + float shorten; + float lineWidth; + float rotXY; + float rotYZ; + float radius; + float rhoOffset; +}; + +static constant float RL_PI = 3.14159265358979323846f; +static constant float3 RL_BOX_HALF = float3(1.0f); +static constant float RL_TRACE_EPSILON = 0.0015f; +static constant float RL_HIT_EPSILON = 0.0001f; +static constant float RL_TMAX = 4.8f; +static constant float RL_SCENE_SCALE = 1.45f; +static constant int RL_MAX_STEPS = 64; + +vertex RecursiveLotusVertexOut recursiveLotusVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant RecursiveLotusUniforms &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; + + RecursiveLotusVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float rlOsc(float time, float v1, float v2) { + return (sin(time * 0.25f) * 0.5f + 0.5f) * (v2 - v1) + v1; +} + +static RLParams rlMakeParams(float time) { + RLParams params; + params.gTime = time + rlOsc(time, 0.0f, 4.0f); + params.density = 26.0f; + params.height = rlOsc(time, 0.0f, 0.41f); + params.cameraY = rlOsc(time, 0.4f, 1.07f); + params.vcut = floor(params.density * 0.25f) * 2.0f + 0.9f; + params.lpscale = floor(params.density) / RL_PI; + params.cameraTy = -0.17f; + params.interpos = -0.5f; + params.shorten = 1.0f; + params.lineWidth = 0.017f; + params.rotXY = 0.0f; + params.rotYZ = 0.785f; + params.radius = 0.05f; + params.rhoOffset = 0.0f; + return params; +} + +static float2 rlRotateAxis(float2 p, float a) { + float c = cos(a); + float s = sin(a); + return c * p + s * float2(p.y, -p.x); +} + +static float2 rlFaceUV(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 rlBoxIntersect(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 rlGain(float x, float k) { + float a = 0.5f * pow(2.0f * ((x < 0.5f) ? x : 1.0f - x), k); + return (x < 0.5f) ? a : 1.0f - a; +} + +static float3 rlGain(float3 v, float k) { + return float3(rlGain(v.x, k), rlGain(v.y, k), rlGain(v.z, k)); +} + +static void rlTile( + float3 p, + thread const RLParams ¶ms, + thread float3 &sp, + thread float3 &tp, + thread float3 &rp, + thread float &mul) +{ + float r = max(length(p), 1.0e-5f); + float3 q = float3( + log(r), + acos(clamp(p.y / r, -1.0f, 1.0f)), + atan2(p.z, p.x)); + + float xshrink = + 1.0f / (abs(q.y - RL_PI) + 1.0e-4f) + + 1.0f / (abs(q.y) + 1.0e-4f) - + 1.0f / RL_PI; + + q.y += params.height; + q.z += q.x * 0.3f; + mul = r / max(params.lpscale * xshrink, 1.0e-5f); + q *= params.lpscale; + sp = q; + + q.x -= params.rhoOffset + params.gTime; + q = fract(q * 0.5f) * 2.0f - 1.0f; + q.x *= xshrink; + tp = q; + q.xy = rlRotateAxis(q.xy, params.rotXY); + q.yz = rlRotateAxis(q.yz, params.rotYZ); + rp = q; +} + +static float rlSdf(float3 p, thread const RLParams ¶ms) { + float3 sp, tp, rp; + float mul; + rlTile(p, params, sp, tp, rp, mul); + + float spheres = abs(rp.x) - 0.012f; + float leaves = max(spheres, max(-rp.y, rp.z)); + leaves = max(leaves, params.vcut - sp.y); + spheres = max(spheres, params.vcut - sp.y + 1.07f); + float ret = min(leaves, spheres); + + float3 pi = rp; + pi.x += params.interpos; + float interS = abs(pi.x) - 0.02f; + float interL = max(interS, max(-rp.y, rp.z)); + interL = max(interL, params.vcut - sp.y + 2.0f); + interS = max(interS, params.vcut - sp.y + 3.0f); + ret = min(ret, min(interL, interS)); + + float ol = abs(rp.y) - params.radius * 0.8f; + ol = min(ol, abs(rp.z) - params.radius * 0.8f); + ret = max(ret, -ol); + + return ret * mul / params.shorten; +} + +static float3 rlColor(float3 p, thread const RLParams ¶ms) { + float3 sp, tp, rp; + float mul; + rlTile(p, params, sp, tp, rp, mul); + + float ol = abs(rp.y) - params.radius; + ol = min(ol, abs(rp.z) - params.radius); + + float3 pi = rp; + pi.x += params.interpos; + float inter = abs(pi.x) - 0.02f; + inter = max(inter, params.vcut - sp.y + 2.0f); + + float dark = smoothstep(params.density * 0.25f, params.density * 0.5f, params.density - sp.y); + dark *= dark; + + if (ol < params.lineWidth) { + return float3(0.6f, 0.6f, 0.8f) * dark; + } + if (inter < 0.02f) { + return float3(0.1f, 0.35f, 0.05f) * dark; + } + return float3(0.1f, 0.15f, 0.25f) * dark; +} + +static float3 rlCalcNormal(float3 pos, thread const RLParams ¶ms) { + float2 e = float2(1.0f, -1.0f) * 0.5773f; + const float eps = 0.0005f; + return normalize( + e.xyy * rlSdf(pos + e.xyy * eps, params) + + e.yyx * rlSdf(pos + e.yyx * eps, params) + + e.yxy * rlSdf(pos + e.yxy * eps, params) + + e.xxx * rlSdf(pos + e.xxx * eps, params)); +} + +fragment float4 recursiveLotusFragment( + RecursiveLotusVertexOut in [[stage_in]], + constant RecursiveLotusUniforms &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) < RL_BOX_HALF - 1.0e-3f); + float2 tBox = rlBoxIntersect(eye, rd, RL_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 + RL_TRACE_EPSILON); + + RLParams params = rlMakeParams(uniforms.time); + float3 ro = marchOrigin * RL_SCENE_SCALE; + float3 sceneRd = rd; + + float angle = 0.11f * sin(params.gTime * 0.05f); + ro.xz = float2(cos(angle) * ro.x - sin(angle) * ro.z, sin(angle) * ro.x + cos(angle) * ro.z); + float2 rotatedXZ = float2( + cos(angle) * sceneRd.x - sin(angle) * sceneRd.z, + sin(angle) * sceneRd.x + cos(angle) * sceneRd.z); + sceneRd = normalize(float3(rotatedXZ.x, sceneRd.y, rotatedXZ.y)); + + float2 q = rlFaceUV(hit); + float3 bg = float3(0.06f, 0.08f, 0.11f) * 0.3f; + bg *= 1.0f - smoothstep(0.1f, 2.0f, length(q * 2.0f - 1.0f)); + + float3 tot = bg; + float t = 0.0f; + float3 pos = ro; + int iout = 0; + for (int i = 0; i < RL_MAX_STEPS; ++i) { + pos = ro + t * sceneRd; + float h = rlSdf(pos, params); + if (h < RL_HIT_EPSILON || t > RL_TMAX) { + break; + } + t += h; + iout = i; + } + + float fSteps = float(iout) / float(RL_MAX_STEPS); + float3 col = float3(0.0f); + if (t < RL_TMAX) { + float3 nor = rlCalcNormal(pos, params); + float dif = clamp(dot(nor, float3(0.57703f)), 0.0f, 1.0f); + float amb = 0.5f + 0.5f * dot(nor, float3(0.0f, 1.0f, 0.0f)); + float3 lotus = rlColor(pos, params); + col = lotus * amb + lotus * dif; + } + + float gloamt = smoothstep(0.04f, 0.5f, length(pos)); + float gainPre = 1.0f - gloamt * 0.6f; + float gainK = 1.5f + gloamt * 2.5f; + col += rlGain(fSteps * float3(0.7f, 0.8f, 0.9f) * gainPre, gainK); + + col = mix(col, bg, smoothstep(0.2f + params.cameraY, 1.6f + params.cameraY, t)); + col = sqrt(max(col, 0.0f)); + tot += col; + return float4(clamp(tot, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/RecursiveLotus/RecursiveLotusTypes.swift b/vr-dive/Demos/RecursiveLotus/RecursiveLotusTypes.swift new file mode 100644 index 0000000..1e45faa --- /dev/null +++ b/vr-dive/Demos/RecursiveLotus/RecursiveLotusTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct RecursiveLotusUniforms in RecursiveLotusShaders.metal. +struct RecursiveLotusUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraRenderer.swift b/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraRenderer.swift new file mode 100644 index 0000000..f1deaa3 --- /dev/null +++ b/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraRenderer.swift @@ -0,0 +1,171 @@ +import Metal +import simd + +// ReflectiveWythoffPolyhedraRenderer.swift +// "Reflective Wythoff polyhedra" — cube-portal adaptation of Shadertoy "ctVGRR" +// Original: https://www.shadertoy.com/view/ctVGRR + +final class ReflectiveWythoffPolyhedraRenderer: VisualPatternController { + let identifier: VisualPatternKind = .reflectiveWythoffPolyhedra + 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 = ReflectiveWythoffPolyhedraRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try ReflectiveWythoffPolyhedraRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = ReflectiveWythoffPolyhedraRenderer.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.3 + } + + 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 = ReflectiveWythoffPolyhedraUniforms( + 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 ReflectiveWythoffPolyhedraRenderer { + 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: "reflectiveWythoffPolyhedraVertex") + desc.fragmentFunction = library.makeFunction(name: "reflectiveWythoffPolyhedraFragment") + 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/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraShaders.metal b/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraShaders.metal new file mode 100644 index 0000000..9808456 --- /dev/null +++ b/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraShaders.metal @@ -0,0 +1,312 @@ +// ReflectiveWythoffPolyhedraShaders.metal +// "Reflective Wythoff polyhedra" — cube-portal adaptation of Shadertoy "ctVGRR" +// Original: https://www.shadertoy.com/view/ctVGRR +// +// Source notes: +// - The original shader constructs a reflective Wythoff polyhedron from three +// mirror planes and ray-traces it from a synthetic orbit camera. +// - This version keeps the fold / distance / trace / bounce logic, but replaces +// the screen-space camera with the real per-eye world ray hitting a 2 m cube. +// - When the viewer is outside the cube, marching starts at the visible cube +// face. When the viewer is inside the cube, marching starts at the eye. +// - The solid itself lives in scene space and is not clipped by the cube bounds, +// so motion around the portal produces real stereo parallax. +// - The original sampled two textures for walls and sky. This Metal version +// uses procedural wall and sky shading instead, so it remains self-contained. + +#include +using namespace metal; + +struct ReflectiveWythoffPolyhedraUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct ReflectiveWythoffPolyhedraVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct RWState { + float3x3 mirrors; + float3x3 triangleVertices; + float3 baseVertex; +}; + +static constant float RW_PI = 3.141592654f; +static constant float RW_EDGE_THICKNESS = 0.05f; +static constant int RW_MAX_TRACE_STEPS = 128; +static constant int RW_MAX_RAY_BOUNCES = 12; +static constant float RW_EPSILON = 1.0e-4f; +static constant float RW_FAR = 20.0f; +static constant float RW_SIZE = 0.675f; +static constant float3 RW_PQR = float3(2.0f, 3.0f, 5.0f); +static constant float3 RW_TRUNCATION = float3(1.0f, 1.0f, 1.0f); +static constant float3 RW_BOX_HALF = float3(1.0f); + +vertex ReflectiveWythoffPolyhedraVertexOut reflectiveWythoffPolyhedraVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant ReflectiveWythoffPolyhedraUniforms &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; + + ReflectiveWythoffPolyhedraVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float rwMin3(float x, float y, float z) { + return min(x, min(y, z)); +} + +static float rwMax3(float x, float y, float z) { + return max(x, max(y, z)); +} + +static float rwHash21(float2 p) { + p = fract(p * float2(123.34f, 456.21f)); + p += dot(p, p + 34.45f); + return fract(p.x * p.y); +} + +static RWState rwInitState() { + RWState state; + + float3 c = cos(RW_PI / RW_PQR); + float sp = sin(RW_PI / RW_PQR.x); + + float3 m1 = float3(1.0f, 0.0f, 0.0f); + float3 m2 = float3(-c.x, sp, 0.0f); + float x3 = -c.z; + float y3 = -(c.y + c.x * c.z) / sp; + float z3 = sqrt(max(1.0f - x3 * x3 - y3 * y3, 0.0f)); + float3 m3 = float3(x3, y3, z3); + + state.mirrors = float3x3(m1, m2, m3); + + float3 t0 = normalize(cross(m2, m3)); + float3 t1 = normalize(cross(m3, m1)); + float3 t2 = normalize(cross(m1, m2)); + state.triangleVertices = float3x3(t0, t1, t2); + + float determinant = dot(m1, cross(m2, m3)); + float3x3 inverseTranspose = float3x3( + cross(m2, m3), + cross(m3, m1), + cross(m1, m2)) / determinant; + state.baseVertex = normalize(inverseTranspose * RW_TRUNCATION) * RW_SIZE; + return state; +} + +static float rwBoxHit(float3 ro, float3 rd, float3 halfExtents, thread float3 &nn, bool entering) { + rd += 0.0001f * (1.0f - abs(sign(rd))); + float3 invRD = 1.0f / rd; + float3 roOverRD = ro * invRD; + float3 extentsOverRD = halfExtents * abs(invRD); + float3 pin = -extentsOverRD - roOverRD; + float3 pout = extentsOverRD - roOverRD; + 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 float3 rwFold(float3 p, thread const RWState &state) { + for (int outer = 0; outer < 5; ++outer) { + for (int mirrorIndex = 0; mirrorIndex < 3; ++mirrorIndex) { + float3 mirror = state.mirrors[mirrorIndex]; + p -= 2.0f * min(dot(p, mirror), 0.0f) * mirror; + } + } + return p; +} + +static float3 rwDistEdges(float3 p, thread const RWState &state) { + p = rwFold(p, state) - state.baseVertex; + + float3 edgeDistance; + for (int mirrorIndex = 0; mirrorIndex < 3; ++mirrorIndex) { + float3 mirror = state.mirrors[mirrorIndex]; + float3 q = p - min(0.0f, dot(p, mirror)) * mirror; + edgeDistance[mirrorIndex] = dot(q, q); + } + return sqrt(edgeDistance); +} + +static float rwMap(float3 p, thread const RWState &state) { + p = rwFold(p, state) - state.baseVertex; + return rwMax3( + dot(p, state.triangleVertices[0]), + dot(p, state.triangleVertices[1]), + dot(p, state.triangleVertices[2])); +} + +static float rwTrace(float3 pos, float3 rd, bool outside, thread const RWState &state) { + float t = 0.0f; + float sgn = outside ? 1.0f : -1.0f; + for (int stepIndex = 0; stepIndex < RW_MAX_TRACE_STEPS; ++stepIndex) { + float d = rwMap(pos + t * rd, state); + if (abs(d) < RW_EPSILON) { + return t; + } + if (t > RW_FAR) { + break; + } + t += sgn * d * 0.9f; + } + return RW_FAR; +} + +static float3 rwGetNormal(float3 pos, thread const RWState &state) { + float3 eps = float3(0.001f, 0.0f, 0.0f); + return normalize(float3( + rwMap(pos + eps.xyy, state) - rwMap(pos - eps.xyy, state), + rwMap(pos + eps.yxy, state) - rwMap(pos - eps.yxy, state), + rwMap(pos + eps.yyx, state) - rwMap(pos - eps.yyx, state))); +} + +static float3 rwWallAlbedo(float2 uv, float time) { + float2 tile = uv * 6.0f; + float2 cell = fract(tile) - 0.5f; + float panel = smoothstep(0.48f, 0.10f, max(abs(cell.x), abs(cell.y))); + float brushed = 0.5f + 0.5f * sin(tile.x * 4.2f + tile.y * 2.7f + time * 0.35f); + float grain = rwHash21(floor(tile * 2.0f)); + float mixValue = clamp(panel * 0.7f + brushed * 0.2f + grain * 0.1f, 0.0f, 1.0f); + return mix(float3(0.08f, 0.09f, 0.11f), float3(0.36f, 0.38f, 0.42f), mixValue); +} + +static float4 rwWallColor(float3 dir, float3 nor, float3 eds, float time) { + float d = rwMin3(eds.x, eds.y, eds.z); + + float3 albedo = rwWallAlbedo(eds.xy * 2.0f, time) * 0.5f; + float lighting = 0.2f + max(dot(nor, normalize(float3(0.8f, 0.5f, 0.0f))), 0.0f); + + if (dot(dir, nor) < 0.0f) { + float f = clamp(d * 1000.0f - 3.0f, 0.0f, 1.0f); + albedo = mix(float3(0.01f), albedo, f); + return float4(albedo * lighting, f); + } + + float m = rwMax3(eds.x, eds.y, eds.z); + float2 a = fract(float2(d, m) * 40.6f + time * float2(0.03f, -0.05f)) - 0.5f; + float aa = dot(a, a); + float b = 0.2f / (aa + 0.2f); + float lightShape = (1.0f - clamp(d * 100.0f - 2.0f, 0.0f, 1.0f)) * b; + + float3 emissive = float3(3.5f, 1.8f, 1.0f); + return float4(mix(albedo * lighting, emissive, lightShape), 0.0f); +} + +static float3 rwBackground(float3 dir, float time) { + float t = clamp(dir.y * 0.5f + 0.5f, 0.0f, 1.0f); + float3 sky = mix(float3(0.02f, 0.025f, 0.035f), float3(0.16f, 0.19f, 0.24f), t); + + float3 sunDir = normalize(float3(0.5f, 0.35f, 0.2f)); + float sun = pow(max(dot(dir, sunDir), 0.0f), 72.0f); + float horizon = pow(1.0f - abs(dir.y), 5.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x + dir.z) * 12.0f + time * 0.6f); + + sky += sun * float3(2.5f, 2.1f, 1.4f); + sky += horizon * shimmer * float3(0.12f, 0.08f, 0.05f); + return sky; +} + +fragment float4 reflectiveWythoffPolyhedraFragment( + ReflectiveWythoffPolyhedraVertexOut in [[stage_in]], + constant ReflectiveWythoffPolyhedraUniforms &uniforms [[buffer(0)]], + constant float4x4 *viewToWorld [[buffer(1)]]) +{ + uint vi = min(in.viewIndex, uniforms.viewCount - 1u); + float4x4 v2w = viewToWorld[vi]; + RWState state = rwInitState(); + + 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 surfacePos = (in.worldPos - center) / sceneScale; + float3 rd = normalize(surfacePos - eye); + + bool insideBox = all(abs(eye) < float3(0.999f)); + float3 faceNormal; + float entryT = insideBox ? 0.0f : rwBoxHit(eye, rd, RW_BOX_HALF, faceNormal, true); + if (!insideBox && entryT < 0.0f) { + discard_fragment(); + } + + float3 pos = insideBox ? (eye + rd * 0.002f) : (eye + rd * (entryT + 0.002f)); + float3 color = float3(0.0f); + float3 transmittance = float3(1.0f); + + if (rwMap(pos, state) > 0.0f) { + float t = rwTrace(pos, rd, true, state); + if (t >= RW_FAR) { + float3 bg = rwBackground(rd, uniforms.time); + bg = bg / (bg * 0.5f + 0.5f); + return float4(clamp(bg, 0.0f, 1.0f), 1.0f); + } + + pos += t * rd; + float3 nor = rwGetNormal(pos, state); + float3 reflectedDir = reflect(rd, nor); + float3 bgColor = rwBackground(reflectedDir, uniforms.time); + float fresnel = 0.04f + 0.96f * pow(1.0f - max(dot(rd, -nor), 0.0f), 5.0f); + color += bgColor * fresnel; + + float3 eds = rwDistEdges(pos, state); + float d = rwMin3(eds.x, eds.y, eds.z); + if (d < RW_EDGE_THICKNESS) { + float4 wc = rwWallColor(rd, nor, eds, uniforms.time); + float3 result = color * wc.a + wc.rgb; + result = result / (result * 0.5f + 0.5f); + return float4(clamp(result, 0.0f, 1.0f), 1.0f); + } + } + + for (int bounceIndex = 0; bounceIndex < RW_MAX_RAY_BOUNCES; ++bounceIndex) { + float t = rwTrace(pos, rd, false, state); + if (t >= RW_FAR) { + color += transmittance * rwBackground(rd, uniforms.time); + break; + } + + pos += t * rd; + float3 eds = rwDistEdges(pos, state); + float3 nor = rwGetNormal(pos, state); + float d = rwMin3(eds.x, eds.y, eds.z); + if (d < RW_EDGE_THICKNESS) { + color += transmittance * rwWallColor(rd, nor, eds, uniforms.time).rgb; + break; + } + + rd = reflect(rd, nor); + pos += rd * 0.005f; + transmittance *= float3(0.4f, 0.7f, 0.7f); + } + + color = color / (color * 0.5f + 0.5f); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraTypes.swift b/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraTypes.swift new file mode 100644 index 0000000..496afb7 --- /dev/null +++ b/vr-dive/Demos/ReflectiveWythoffPolyhedra/ReflectiveWythoffPolyhedraTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct ReflectiveWythoffPolyhedraUniforms in +/// ReflectiveWythoffPolyhedraShaders.metal. +struct ReflectiveWythoffPolyhedraUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronRenderer.swift b/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronRenderer.swift new file mode 100644 index 0000000..93c7b21 --- /dev/null +++ b/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronRenderer.swift @@ -0,0 +1,175 @@ +import Metal +import simd + +final class RhombicDodecahedronRenderer: VisualPatternController { + let identifier: VisualPatternKind = .rhombicDodecahedron + 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 + + // Half-distance from dodecahedron centre to each face. + // Circumscribed sphere radius = roomScale * sqrt(2) ≈ 1.414 * roomScale. + private let roomScale: Float = 0.50 + + // Fixed world-space position of the dodecahedron (~0.9 m ahead, slightly below eye level). + private let objectCenter = SIMD3(0.0, -0.1, -0.9) + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + // Build a UV sphere whose radius just exceeds the dodecahedron's circumscribed sphere. + let boundingRadius = roomScale * sqrt(2.0) * 1.04 + let geo = RhombicDodecahedronRenderer.makeUVSphere( + device: device, radius: boundingRadius, latSegments: 24, lonSegments: 48) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try RhombicDodecahedronRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = RhombicDodecahedronRenderer.makeDepthStencilState(device: device) + } + + func updateSimulation(_ context: PatternSimulationContext) {} + func resetToInitialState() {} + + func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) { + encoder.setRenderPipelineState(pipelineState) + encoder.setDepthStencilState(depthStencilState) + // No culling: discard_fragment() in the shader handles silhouette correctly. + encoder.setCullMode(.none) + + context.applyViewConfiguration(on: encoder) + + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + + var uniforms = RhombicDodecahedronUniforms( + time: context.time, + viewCount: UInt32(context.viewData.viewCount), + roomScale: roomScale, + reflectionBounces: 20, + 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) + } + } + + // Fragment uniforms + 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) + } + } + + vpMatrices.withUnsafeBytes { + if let base = $0.baseAddress, $0.count > 0 { + encoder.setFragmentBytes(base, length: $0.count, index: 2) + } + } + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: indexCount, + indexType: .uint16, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } +} + +extension RhombicDodecahedronRenderer { + // UV sphere centred at the origin. lat=0 is the north pole (y=+1). + // latSegments × lonSegments quads, each split into 2 triangles. + // Total vertices: (latSegments+1) × (lonSegments+1) ≤ 25×49 = 1225 → fits UInt16. + fileprivate static func makeUVSphere( + device: MTLDevice, + radius: Float, + latSegments: Int, + lonSegments: Int + ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) { + var vertices: [MeshVertex] = [] + var indices: [UInt16] = [] + 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 n = SIMD3(cos(phi) * sinTheta, cosTheta, sin(phi) * sinTheta) + vertices.append(MeshVertex(position: n * radius, normal: n)) + } + } + + let stride = UInt16(lonSegments + 1) + for lat in 0...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 descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "rhombicDodecahedronVertex") + descriptor.fragmentFunction = library.makeFunction(name: "rhombicDodecahedronFragment") + descriptor.colorAttachments[0].pixelFormat = .rgba16Float + descriptor.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 + descriptor.vertexDescriptor = vd + + descriptor.maxVertexAmplificationCount = max(maxViewCount, 1) + return try device.makeRenderPipelineState(descriptor: descriptor) + } + + fileprivate static func makeDepthStencilState(device: MTLDevice) -> MTLDepthStencilState { + let desc = MTLDepthStencilDescriptor() + desc.depthCompareFunction = .greater // reverse-Z: near=1, far=0 + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} diff --git a/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronShaders.metal b/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronShaders.metal new file mode 100644 index 0000000..77972b4 --- /dev/null +++ b/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronShaders.metal @@ -0,0 +1,246 @@ +#include +using namespace metal; + +// Layout must match the Swift struct RhombicDodecahedronUniforms. +struct RhombicDodecahedronUniforms { + float time; + uint viewCount; + float roomScale; // half-distance from centre to each face + uint reflectionBounces; + float4 objectCenter; // xyz = world position of dodecahedron centre +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +// ── Vertex shader ──────────────────────────────────────────────────────────── +// Renders a bounding sphere positioned at objectCenter. +// Passes the interpolated world-space position to the fragment for ray setup. + +struct RhombicVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex RhombicVertexOut rhombicDodecahedronVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant RhombicDodecahedronUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + MeshVertex vtx = vertices[vertexID]; + float3 worldPos = vtx.position + uniforms.objectCenter.xyz; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + + RhombicVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ── Ray vs rhombic dodecahedron ────────────────────────────────────────────── +// The 12 face normals (normalised). Pre-computed literals avoid Metal's +// restriction on global constructors (no function calls at global scope). +// 1/sqrt(2) ≈ 0.70710678118654752 +#define INV_SQRT2 0.70710678118654752f + +constant float3 kRhombicNormals[12] = { + { INV_SQRT2, INV_SQRT2, 0.0f}, + { INV_SQRT2, -INV_SQRT2, 0.0f}, + {-INV_SQRT2, INV_SQRT2, 0.0f}, + {-INV_SQRT2, -INV_SQRT2, 0.0f}, + { INV_SQRT2, 0.0f, INV_SQRT2}, + { INV_SQRT2, 0.0f, -INV_SQRT2}, + {-INV_SQRT2, 0.0f, INV_SQRT2}, + {-INV_SQRT2, 0.0f, -INV_SQRT2}, + {0.0f, INV_SQRT2, INV_SQRT2}, + {0.0f, INV_SQRT2, -INV_SQRT2}, + {0.0f, -INV_SQRT2, INV_SQRT2}, + {0.0f, -INV_SQRT2, -INV_SQRT2}, +}; + +// Returns the parametric interval [tEntry, tExit] for a ray (origin, dir). +// If tEntry > tExit or tExit < 0 the ray misses. +struct RayHit { + float tEntry; + float tExit; + int entryFace; // index of the face the ray enters through, or -1 +}; + +RayHit rhombicRayHit(float3 origin, float3 dir, float s) +{ + float tEntry = -1e9f; + float tExit = 1e9f; + int entryFace = -1; + + for (int i = 0; i < 12; i++) { + float3 n = kRhombicNormals[i]; + float denom = dot(n, dir); + float dist = s - dot(n, origin); // positive when origin is inside this half-space + + if (abs(denom) < 1e-7f) { + if (dist < 0.0f) { + // Origin outside a parallel half-space → ray always misses + RayHit miss; miss.tEntry = 1.0f; miss.tExit = -1.0f; miss.entryFace = -1; + return miss; + } + continue; + } + + float t = dist / denom; + if (denom < 0.0f) { // ray enters this half-space + if (t > tEntry) { tEntry = t; entryFace = i; } + } else { // ray exits this half-space + if (t < tExit) { tExit = t; } + } + } + + RayHit r; r.tEntry = tEntry; r.tExit = tExit; r.entryFace = entryFace; + return r; +} + +// ── Mirror bounce tracing ──────────────────────────────────────────────────── +struct BounceResult { + float3 dir; + float3 lastNormal; + int bounceCount; + float minEdgeDist; // min distance-to-edge across all bounce points (0 = on edge) +}; + +// Simple integer hash for per-pixel noise — no global constructor, no texture. +inline uint pcgHash(uint v) +{ + uint state = v * 747796405u + 2891336453u; + uint word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u; + return (word >> 22u) ^ word; +} + +BounceResult traceMirrorBounces(float3 pos, float3 dir, float scale, + uint maxBounces, uint noiseSeed) +{ + // Hard cap: never exceed 20 bounces regardless of the uniform. + const uint kHardMax = 20u; + maxBounces = min(maxBounces, kHardMax); + + // Dither: ~10 % of pixels skip 1 bounce to spread compute load. + // pcgHash returns uniform bits; (rng % 10u == 0u) is true ~10 % of the time. + uint rng = pcgHash(noiseSeed); + uint budget = maxBounces - ((rng % 10u == 0u) ? 1u : 0u); + + BounceResult r; + r.dir = dir; + r.lastNormal = float3(0, 1, 0); + r.bounceCount = 0; + r.minEdgeDist = 1.0f; + + for (uint b = 0u; b < budget; b++) { + float tMin = 1e9f; + int hitFace = -1; + + for (int i = 0; i < 12; i++) { + float denom = dot(kRhombicNormals[i], dir); + if (denom > 1e-5f) { + float t = (scale - dot(kRhombicNormals[i], pos)) / denom; + if (t > 1e-4f && t < tMin) { tMin = t; hitFace = i; } + } + } + + if (hitFace < 0) break; + + pos = pos + dir * tMin; + r.lastNormal = kRhombicNormals[hitFace]; + dir = reflect(dir, kRhombicNormals[hitFace]); + r.dir = dir; + r.bounceCount++; + + // Distance to nearest edge: (scale - dot(n_j, p)) / scale. + float minGap = 1.0f; + for (int j = 0; j < 12; j++) { + if (j == hitFace) continue; + float gap = (scale - dot(kRhombicNormals[j], pos)) / scale; + if (gap < minGap) minGap = gap; + } + if (minGap < r.minEdgeDist) r.minEdgeDist = minGap; + } + + return r; +} + +// ── Colour mapping ─────────────────────────────────────────────────────────── +// Edges are sharp bright white; faces are a uniform dark colour. No glow. +float3 bounceColor(BounceResult bounce) +{ + const float kEdgeWidth = 0.015f; + + // step() = hard edge, no glow. + float onEdge = step(bounce.minEdgeDist, kEdgeWidth); + + float3 faceColor = float3(0.04f, 0.04f, 0.06f); + float3 edgeColor = float3(1.00f, 0.97f, 0.93f); + + return mix(faceColor, edgeColor, onEdge); +} + +// ── Fragment shader ────────────────────────────────────────────────────────── +// Fragment receives worldPos on the bounding sphere surface. +// We reconstruct the ray from the eye through that point, intersect it with +// the rhombic dodecahedron, and trace mirror bounces inside. +// Fragments that miss the dodecahedron are discarded so the object has the +// correct silhouette. Custom depth is written at the entry surface so the +// object composites correctly against other geometry. + +struct RhombicFragOut { + float4 color [[color(0)]]; + float depth [[depth(any)]]; +}; + +fragment RhombicFragOut rhombicDodecahedronFragment( + RhombicVertexOut in [[stage_in]], + constant RhombicDodecahedronUniforms &uniforms [[buffer(0)]], + constant float4x4 *viewToWorldTransforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]]) +{ + uint viewIndex = min(in.viewIndex, uniforms.viewCount - 1u); + + // Eye world position: column 3 of the view-to-world matrix. + float3 eyeWorld = viewToWorldTransforms[viewIndex][3].xyz; + float3 rayDir = normalize(in.worldPos - eyeWorld); + + // Work in dodecahedron-local space (centred at objectCenter). + float3 localEye = eyeWorld - uniforms.objectCenter.xyz; + + RayHit hit = rhombicRayHit(localEye, rayDir, uniforms.roomScale); + + // Discard fragments that miss the dodecahedron. + if (hit.tEntry > hit.tExit || hit.tExit < 0.0f) { + discard_fragment(); + } + + // Start at the entry surface (or from inside if the eye is already inside). + float tStart = max(hit.tEntry, 0.001f); + float3 entryLocal = localEye + rayDir * tStart; + + // Per-pixel noise seed derived from fragment position and view index, + // used to dither bounce budget across pixels. + uint noiseSeed = uint(in.clipPos.x) * 1973u + uint(in.clipPos.y) * 9277u + viewIndex * 26699u; + + BounceResult bounce = traceMirrorBounces( + entryLocal, rayDir, uniforms.roomScale, uniforms.reflectionBounces, noiseSeed); + + float3 color = bounceColor(bounce); + + // Compute depth at the entry surface so depth-compositing with other geometry works. + float3 entryWorld = entryLocal + uniforms.objectCenter.xyz; + float4 clipEntry = vpMatrices[viewIndex] * float4(entryWorld, 1.0f); + float ndcDepth = clipEntry.z / clipEntry.w; + + RhombicFragOut out; + out.color = float4(color, 1.0f); + out.depth = clamp(ndcDepth, 0.0f, 1.0f); + return out; +} diff --git a/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronTypes.swift b/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronTypes.swift new file mode 100644 index 0000000..467a3cd --- /dev/null +++ b/vr-dive/Demos/RhombicDodecahedron/RhombicDodecahedronTypes.swift @@ -0,0 +1,9 @@ +import simd + +struct RhombicDodecahedronUniforms { + var time: Float + var viewCount: UInt32 + var roomScale: Float // half-distance from centre to each face + var reflectionBounces: UInt32 + var objectCenter: SIMD4 // xyz = world position of dodecahedron centre +} diff --git a/vr-dive/Demos/SaturdayTorus/SaturdayTorusRenderer.swift b/vr-dive/Demos/SaturdayTorus/SaturdayTorusRenderer.swift new file mode 100644 index 0000000..2efa687 --- /dev/null +++ b/vr-dive/Demos/SaturdayTorus/SaturdayTorusRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// SaturdayTorusRenderer.swift +// +// Cube-container adaptation of ShaderToy "Saturday Torus" (fd33zn). +// 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 SaturdayTorusRenderer: VisualPatternController { + let identifier: VisualPatternKind = .saturdayTorus + 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 = SaturdayTorusRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SaturdayTorusRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SaturdayTorusRenderer.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 = SaturdayTorusUniforms( + 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 SaturdayTorusRenderer { + 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: "saturdayTorusVertex") + desc.fragmentFunction = library.makeFunction(name: "saturdayTorusFragment") + 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/SaturdayTorus/SaturdayTorusShaders.metal b/vr-dive/Demos/SaturdayTorus/SaturdayTorusShaders.metal new file mode 100644 index 0000000..2ac6d8f --- /dev/null +++ b/vr-dive/Demos/SaturdayTorus/SaturdayTorusShaders.metal @@ -0,0 +1,316 @@ +// SaturdayTorusShaders.metal +// Adapted from ShaderToy "Saturday Torus". +// Source: https://www.shadertoy.com/view/fd33zn +// Shader header declares: License CC0: Saturday Torus. +// Embedded helpers retained from the original source: +// - rayTorus / torusNormal: MIT, Inigo Quilez +// - tanh_approx: original source marks author/license as unknown +// +// Metal adaptation notes: +// - The original ShaderToy used screen-space fragCoord and a fixed camera. +// This version reconstructs the real per-eye world ray and starts it at the +// visible 2 m cube surface, or at the eye when the camera is inside. +// - The torus itself is not clipped to the container bounds after entry. +// - GLSL macros and mat2 rotation helpers are expanded into explicit Metal +// constants and functions. + +#include +using namespace metal; + +struct SaturdayTorusUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct SaturdayTorusMeshVertex { + float3 position; + float3 normal; +}; + +struct SaturdayTorusVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float ST_PI = 3.141592654f; +static constant float ST_TAU = 6.28318530718f; +static constant float3 ST_BOX_HALF = float3(1.0f); +static constant float2 ST_TORUS = 0.55f * float2(1.0f, 0.75f); + +vertex SaturdayTorusVertexOut saturdayTorusVertex( + ushort amplificationID [[amplification_id]], + const device SaturdayTorusMeshVertex *vertices [[buffer(0)]], + constant SaturdayTorusUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + SaturdayTorusMeshVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz; + + SaturdayTorusVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 stRotate(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 stRotateScene(float3 p, float time) { + p.xy = stRotate(p.xy, 0.35f * sin(time * 0.23f)); + p.xz = stRotate(p.xz, 0.55f + time * 0.08f); + p.yz = stRotate(p.yz, 0.65f); + return p; +} + +static float2 stBoxIntersect(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 rayTorus(float3 ro, float3 rd, float2 tor) { + float po = 1.0f; + + float Ra2 = tor.x * tor.x; + float ra2 = tor.y * tor.y; + + float m = dot(ro, ro); + float n = dot(ro, rd); + + float h = n * n - m + (tor.x + tor.y) * (tor.x + tor.y); + if (h < 0.0f) { + return -1.0f; + } + + float k = (m - ra2 - Ra2) * 0.5f; + float k3 = n; + float k2 = n * n + Ra2 * rd.z * rd.z + k; + float k1 = k * n + Ra2 * ro.z * rd.z; + float k0 = k * k + Ra2 * ro.z * ro.z - Ra2 * ra2; + + if (abs(k3 * (k3 * k3 - k2) + k1) < 0.01f) { + po = -1.0f; + float tmp = k1; + k1 = k3; + k3 = tmp; + k0 = 1.0f / max(k0, 1.0e-6f); + k1 *= k0; + k2 *= k0; + k3 *= k0; + } + + float c2 = 2.0f * k2 - 3.0f * k3 * k3; + float c1 = k3 * (k3 * k3 - k2) + k1; + float c0 = k3 * (k3 * (-3.0f * k3 * k3 + 4.0f * k2) - 8.0f * k1) + 4.0f * k0; + + c2 /= 3.0f; + c1 *= 2.0f; + c0 /= 3.0f; + + float Q = c2 * c2 + c0; + float R = 3.0f * c0 * c2 - c2 * c2 * c2 - c1 * c1; + + h = R * R - Q * Q * Q; + float z = 0.0f; + if (h < 0.0f) { + float sQ = sqrt(max(Q, 0.0f)); + float denom = max(sQ * Q, 1.0e-6f); + z = 2.0f * sQ * cos(acos(clamp(R / denom, -1.0f, 1.0f)) / 3.0f); + } else { + float sQ = pow(sqrt(max(h, 0.0f)) + abs(R), 1.0f / 3.0f); + float safeSQ = max(sQ, 1.0e-6f); + z = copysign(abs(safeSQ + Q / safeSQ), R); + } + z = c2 - z; + + float d1 = z - 3.0f * c2; + float d2 = z * z - 3.0f * c0; + if (abs(d1) < 1.0e-4f) { + if (d2 < 0.0f) { + return -1.0f; + } + d2 = sqrt(d2); + } else { + if (d1 < 0.0f) { + return -1.0f; + } + d1 = sqrt(d1 * 0.5f); + d2 = c1 / d1; + } + + float result = 1.0e20f; + + h = d1 * d1 - z + d2; + if (h > 0.0f) { + h = sqrt(h); + float t1 = -d1 - h - k3; + t1 = (po < 0.0f) ? 2.0f / t1 : t1; + float t2 = -d1 + h - k3; + t2 = (po < 0.0f) ? 2.0f / t2 : t2; + if (t1 > 0.0f) { + result = t1; + } + if (t2 > 0.0f) { + result = min(result, t2); + } + } + + h = d1 * d1 - z - d2; + if (h > 0.0f) { + h = sqrt(h); + float t1 = d1 - h - k3; + t1 = (po < 0.0f) ? 2.0f / t1 : t1; + float t2 = d1 + h - k3; + t2 = (po < 0.0f) ? 2.0f / t2 : t2; + if (t1 > 0.0f) { + result = min(result, t1); + } + if (t2 > 0.0f) { + result = min(result, t2); + } + } + + return result < 1.0e19f ? result : -1.0f; +} + +static float3 torusNormal(float3 pos, float2 tor) { + float sq = dot(pos, pos) - tor.y * tor.y; + return normalize(pos * (sq - tor.x * tor.x * float3(1.0f, 1.0f, -1.0f))); +} + +static float tanhApprox(float x) { + float x2 = x * x; + return clamp(x * (27.0f + x2) / (27.0f + 9.0f * x2), -1.0f, 1.0f); +} + +static float2 cubeFaceUV(float3 p) { + float3 ap = abs(p); + float2 uv = float2(0.5f); + 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 postProcess(float3 col, float2 q) { + col = clamp(col, 0.0f, 1.0f); + col = pow(col, 1.0f / float3(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); + float vignette = 0.5f + 0.5f * pow(max(19.0f * q.x * q.y * (1.0f - q.x) * (1.0f - q.y), 0.0f), 0.7f); + return col * vignette; +} + +static float3 saturdayTorusColor(float3 ro, float3 rd, float2 q, float time) { + float td = rayTorus(ro, rd, ST_TORUS); + + float3 background = mix( + float3(0.02f, 0.02f, 0.025f), + float3(0.14f, 0.14f, 0.16f), + 0.25f + 0.75f * clamp(0.5f + 0.5f * rd.z, 0.0f, 1.0f)); + background *= 0.7f + 0.3f * (0.5f + 0.5f * cos(ST_TAU * q.x + time * 0.3f)); + + if (td <= 0.0f) { + return background; + } + + float3 tpos = ro + rd * td; + float3 outwardNormal = torusNormal(tpos, ST_TORUS); + // Detect whether we hit the inner wall (camera is inside the tube). + bool hitFromInside = dot(outwardNormal, rd) > 0.0f; + // viewNormal always faces against the incoming ray direction. + float3 viewNormal = hitFromInside ? outwardNormal : -outwardNormal; + float3 tref = reflect(rd, viewNormal); + + float3 lp1 = float3(0.0f, 0.75f, -0.2f); + lp1.xy = stRotate(lp1.xy, 0.85f); + lp1.xz = stRotate(lp1.xz, -0.5f); + + float3 ldif1 = lp1 - tpos; + float ldd1 = max(dot(ldif1, ldif1), 1.0e-4f); + float ldl1 = sqrt(ldd1); + float3 ld1 = ldif1 / ldl1; + float3 sro = tpos + 0.05f * viewNormal; + float sd = rayTorus(sro, ld1, ST_TORUS); + + float dif1 = max(dot(viewNormal, ld1), 0.0f); + float spe1 = pow(max(dot(tref, ld1), 0.0f), 10.0f); + float r = length(tpos.xy); + float denom = max(r + 0.5f * abs(tpos.z), 1.0e-3f); + float a = atan2(tpos.y, tpos.x) - ST_PI * tpos.z / denom - ST_TAU * time / 45.0f; + float phase = 9.0f * a; + float s = mix(0.08f, 0.32f, tanhApprox(2.0f * abs(td - 0.75f))); + float aa = max(fwidth(phase) * 0.75f, 0.035f); + float stripeWidth = max(s, aa); + float3 bcol0 = float3(0.3f); + float3 bcol1 = float3(0.025f); + float stripe = smoothstep(-stripeWidth, stripeWidth, sin(phase)); + float3 tcol = mix(bcol0, bcol1, stripe); + + float fresnel = sqrt(abs(dot(rd, viewNormal))); + float3 col = background * 0.2f; + col += tcol * mix(0.2f, 1.0f, dif1 / ldd1) + 0.25f * spe1; + col *= fresnel; + + // The original self-shadow path relies on a second torus hit and is stable + // for the fixed 2D camera, but in VR close-up views it produces moire-like + // speckle and interior cracks. Use a softer distance-only occlusion instead. + if (!hitFromInside && dif1 > 0.0f && sd > 0.08f && sd < ldl1) { + float occlusion = 1.0f - smoothstep(0.08f, 0.36f, sd); + col *= mix(1.0f, 0.58f, occlusion); + } + + return max(col, 0.0f); +} + +fragment float4 saturdayTorusFragment( + SaturdayTorusVertexOut in [[stage_in]], + constant SaturdayTorusUniforms &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) < ST_BOX_HALF - 1.0e-3f); + float2 tBox = stBoxIntersect(eye, rd, ST_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 + 1.0e-3f); + float2 q = cubeFaceUV(hit); + + float3 sceneRo = stRotateScene(ro, uniforms.time); + float3 sceneRd = normalize(stRotateScene(rd, uniforms.time)); + + float3 col = saturdayTorusColor(sceneRo, sceneRd, q, uniforms.time); + col = postProcess(col, q); + return float4(col, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/SaturdayTorus/SaturdayTorusTypes.swift b/vr-dive/Demos/SaturdayTorus/SaturdayTorusTypes.swift new file mode 100644 index 0000000..7148c72 --- /dev/null +++ b/vr-dive/Demos/SaturdayTorus/SaturdayTorusTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct SaturdayTorusUniforms in SaturdayTorusShaders.metal. +struct SaturdayTorusUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessRenderer.swift b/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessRenderer.swift new file mode 100644 index 0000000..c32a872 --- /dev/null +++ b/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// SaturdayWeirdnessRenderer.swift +// +// Cube-container adaptation of ShaderToy "Saturday weirdness" (43jXWt). +// 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 SaturdayWeirdnessRenderer: VisualPatternController { + let identifier: VisualPatternKind = .saturdayWeirdness + 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 = SaturdayWeirdnessRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SaturdayWeirdnessRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SaturdayWeirdnessRenderer.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 = SaturdayWeirdnessUniforms( + 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 SaturdayWeirdnessRenderer { + 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: "saturdayWeirdnessVertex") + desc.fragmentFunction = library.makeFunction(name: "saturdayWeirdnessFragment") + 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)! + } +} \ No newline at end of file diff --git a/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessShaders.metal b/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessShaders.metal new file mode 100644 index 0000000..fd6c702 --- /dev/null +++ b/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessShaders.metal @@ -0,0 +1,260 @@ +// SaturdayWeirdnessShaders.metal +// Adapted from ShaderToy "Saturday weirdness". +// Source: https://www.shadertoy.com/view/43jXWt +// +// Metal adaptation notes: +// - The supplied ShaderToy code is the final FXAA resolve pass over iChannel0. +// The original visual source is not present in the provided snippet, so this +// cube-container version rebuilds a scene with the same intent: dense, +// high-contrast weird structures with softened edges. +// - Instead of a post-process over a full-screen buffer, this version ray- +// marches a procedural 3D field from the cube boundary and uses derivative- +// aware softening near band edges as an in-shader substitute for the FXAA +// resolve idea. + +#include +using namespace metal; + +struct SaturdayWeirdnessUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SaturdayWeirdnessVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct SWHit { + float distance; + float sdf; + float feature; + float3 albedo; + bool hit; +}; + +static constant float3 SW_BOX_HALF = float3(1.0f); +static constant float SW_MAX_DIST = 28.0f; +static constant float SW_HIT_EPSILON = 0.0015f; +static constant int SW_MAX_STEPS = 128; +static constant int SW_VOLUME_STEPS = 20; + +vertex SaturdayWeirdnessVertexOut saturdayWeirdnessVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SaturdayWeirdnessUniforms &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; + + SaturdayWeirdnessVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 swRotate2D(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 swRoundBox(float3 p, float3 b, float r) { + float3 q = abs(p) - b; + return length(max(q, 0.0f)) + min(max(q.x, max(q.y, q.z)), 0.0f) - r; +} + +static float3 swWarp(float3 p, float time) { + p *= 1.45f; + p.xz = swRotate2D(p.xz, time * 0.17f); + p.yz = swRotate2D(p.yz, 0.52f + sin(time * 0.28f) * 0.16f); + p.xy = swRotate2D(p.xy, 0.22f * sin(time * 0.19f)); + return p; +} + +static float swGyroid(float3 p) { + return dot(sin(p), cos(p.zxy)); +} + +static float3 swPalette(float t) { + float u = 0.5f + 0.5f * sin(t); + float v = 0.5f + 0.5f * sin(t * 0.72f + 1.2f); + float w = 0.5f + 0.5f * cos(t * 1.1f - 0.4f); + float3 deep = float3(0.04f, 0.08f, 0.28f); + float3 blue = float3(0.14f, 0.42f, 0.92f); + float3 cyan = float3(0.64f, 0.92f, 1.0f); + float3 magenta = float3(0.86f, 0.2f, 0.96f); + return mix(mix(deep, blue, u), mix(magenta, cyan, w), v); +} + +static float swGeometry(float3 p, float time) { + float3 q = swWarp(p, time); + return swRoundBox(q, float3(0.72f), 0.24f); +} + +static float swFlow(float3 p, float time) { + float3 q = swWarp(p, time); + float gyroid = swGyroid(q * 2.55f + time * 0.45f); + float swirl = atan2(q.y, q.x); + float ribbons = sin(length(q.xy) * 8.6f - swirl * 4.2f + q.z * 3.3f - time * 0.8f); + float bands = sin(q.x * 3.1f - q.z * 4.0f + gyroid * 1.4f) + + 0.55f * sin(q.y * 4.9f + time * 0.55f) + + 0.35f * cos(q.z * 4.4f - q.x * 1.2f); + return ribbons + bands * 0.65f + gyroid * 0.45f; +} + +static float2 swBoxIntersect(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 swFaceUV(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 SWHit swMap(float3 p, float time) { + SWHit hit; + float sdf = swGeometry(p, time); + float flow = swFlow(p, time); + float feature = 0.5f + 0.5f * sin(flow * 1.8f); + + hit.distance = 0.0f; + hit.sdf = sdf; + hit.feature = feature; + hit.albedo = swPalette(flow); + hit.hit = false; + return hit; +} + +static float3 swNormal(float3 p, float time) { + float2 e = float2(0.0025f, 0.0f); + return normalize(float3( + swGeometry(p + e.xyy, time) - swGeometry(p - e.xyy, time), + swGeometry(p + e.yxy, time) - swGeometry(p - e.yxy, time), + swGeometry(p + e.yyx, time) - swGeometry(p - e.yyx, time))); +} + +static float3 swEnvironment(float3 dir) { + 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), 5.0f); + float sun = pow(max(dot(dir, normalize(float3(-0.45f, 0.4f, -0.8f))), 0.0f), 48.0f); + float3 sky = mix(float3(0.01f, 0.025f, 0.06f), float3(0.08f, 0.18f, 0.34f), skyMix); + return sky + float3(0.16f, 0.34f, 0.62f) * horizon * 0.28f + float3(0.92f, 0.97f, 1.0f) * sun * 0.7f; +} + +static float3 swInnerColor(float3 p, float3 n, float time) { + float3 upRef = abs(n.y) > 0.95f ? float3(1.0f, 0.0f, 0.0f) : float3(0.0f, 1.0f, 0.0f); + float3 tangent = normalize(cross(upRef, n)); + float3 bitangent = cross(n, tangent); + + float3 col = float3(0.0f); + for (int i = 0; i < SW_VOLUME_STEPS; ++i) { + float depth = 0.03f + 0.05f * float(i); + float lateral = sin(time * 0.35f + depth * 11.0f + dot(p, tangent) * 4.0f) * 0.012f; + float swirl = cos(time * 0.28f + depth * 9.0f + dot(p, bitangent) * 4.5f) * 0.012f; + float3 pos = p - n * depth + tangent * lateral + bitangent * swirl; + + float sdf = swGeometry(pos, time); + float shell = exp(-24.0f * abs(sdf)); + float flow = swFlow(pos, time); + float field = 0.5f + 0.5f * sin(flow * 2.1f + depth * 8.0f); + float density = shell * (0.3f + 0.7f * field); + float fade = exp(-0.22f * float(i)); + float3 tint = swPalette(flow + depth * 0.6f); + + col += tint * density * fade * 0.16f; + } + + return col; +} + +fragment float4 saturdayWeirdnessFragment( + SaturdayWeirdnessVertexOut in [[stage_in]], + constant SaturdayWeirdnessUniforms &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) < SW_BOX_HALF - 1.0e-3f); + float2 tBox = swBoxIntersect(eye, rd, SW_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 + SW_HIT_EPSILON)) * 2.0f; + + float travel = 0.0f; + SWHit state = swMap(ro, uniforms.time); + for (int i = 0; i < SW_MAX_STEPS; ++i) { + float3 p = ro + rd * travel; + state = swMap(p, uniforms.time); + if (abs(state.sdf) < SW_HIT_EPSILON || travel > SW_MAX_DIST) { + break; + } + travel += clamp(abs(state.sdf), 0.01f, 0.12f); + } + + if (travel > SW_MAX_DIST || abs(state.sdf) >= 0.02f) { + float2 uv = swFaceUV(hit) * 2.0f - 1.0f; + float vignette = 1.0f - 0.25f * dot(uv, uv); + float3 bg = swEnvironment(rd) * vignette * vignette; + return float4(bg, 1.0f); + } + + float3 p = ro + rd * travel; + float3 n = swNormal(p, uniforms.time); + float3 reflected = reflect(rd, n); + + float diffuse = clamp(dot(n, normalize(float3(-0.32f, 0.76f, -0.56f))), 0.0f, 1.0f); + float fresnel = pow(max(1.0f - abs(dot(n, -rd)), 0.0f), 4.0f); + float featureWidth = 0.06f; + float sheenMask = smoothstep(0.54f - featureWidth, 0.82f + featureWidth, state.feature); + + float3 env = swEnvironment(reflected); + float3 inner = swInnerColor(p - n * 0.02f, n, uniforms.time); + float3 color = state.albedo * (0.3f + 0.7f * diffuse); + color += inner; + color = mix(color, state.albedo * 1.18f + inner * 0.7f, sheenMask * 0.55f); + color += env * (0.08f + 0.14f * fresnel); + color += float3(0.85f, 0.95f, 1.0f) * sheenMask * 0.08f; + + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessTypes.swift b/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessTypes.swift new file mode 100644 index 0000000..27453e5 --- /dev/null +++ b/vr-dive/Demos/SaturdayWeirdness/SaturdayWeirdnessTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct SaturdayWeirdnessUniforms in SaturdayWeirdnessShaders.metal. +struct SaturdayWeirdnessUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyRenderer.swift b/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyRenderer.swift new file mode 100644 index 0000000..6cf367a --- /dev/null +++ b/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyRenderer.swift @@ -0,0 +1,171 @@ +import Metal +import simd + +// ShaderdoughFairyRenderer.swift +// "Shaderdough fairy" — cube-portal adaptation of Shadertoy "4lGyW1" +// Original: https://www.shadertoy.com/view/4lGyW1 + +final class ShaderdoughFairyRenderer: VisualPatternController { + let identifier: VisualPatternKind = .shaderdoughFairy + 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 = ShaderdoughFairyRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try ShaderdoughFairyRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = ShaderdoughFairyRenderer.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 = ShaderdoughFairyUniforms( + 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 ShaderdoughFairyRenderer { + 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: "shaderdoughFairyVertex") + desc.fragmentFunction = library.makeFunction(name: "shaderdoughFairyFragment") + 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/ShaderdoughFairy/ShaderdoughFairyShaders.metal b/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyShaders.metal new file mode 100644 index 0000000..cd48fa3 --- /dev/null +++ b/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyShaders.metal @@ -0,0 +1,308 @@ +// ShaderdoughFairyShaders.metal +// "Shaderdough fairy" — cube-portal adaptation of Shadertoy "4lGyW1" +// Original: https://www.shadertoy.com/view/4lGyW1 +// +// Metal adaptation notes: +// - The original GLSL builds a synthetic screen-space camera and marches a +// glowing twisted icosahedral field from that camera. +// - This version uses the actual per-eye world ray instead. When the viewer is +// outside the 2 m cube, marching starts at the visible cube surface. When the +// viewer is inside the cube, marching starts at the eye position. +// - The scene itself is fixed in scene space around the cube centre and is not +// clipped by the cube bounds, so head motion reveals true stereo parallax +// instead of a 2D image attached to the cube wall. + +#include +using namespace metal; + +struct ShaderdoughFairyUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct ShaderdoughFairyVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct ShaderdoughFairyIcosahedronData { + float3 nc; + float3 pca; +}; + +struct ShaderdoughFairyModel { + float dist; + float3 colour; + float id; +}; + +static constant float SF_PI = 3.14159265359f; +static constant float SF_PHI = 1.618033988749895f; +static constant float SF_MAX_TRACE_DISTANCE = 6.0f; +static constant float SF_INTERSECTION_PRECISION = 0.001f; +static constant float SF_FUDGE_FACTOR = 0.2f; + +vertex ShaderdoughFairyVertexOut shaderdoughFairyVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant ShaderdoughFairyUniforms &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; + + ShaderdoughFairyVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3x3 sfRotationMatrix(float3 axis, float angle) { + axis = normalize(axis); + float s = sin(angle); + float c = cos(angle); + float oc = 1.0f - c; + return float3x3( + float3(oc * axis.x * axis.x + c, + oc * axis.x * axis.y - axis.z * s, + oc * axis.z * axis.x + axis.y * s), + float3(oc * axis.x * axis.y + axis.z * s, + oc * axis.y * axis.y + c, + oc * axis.y * axis.z - axis.x * s), + float3(oc * axis.z * axis.x - axis.y * s, + oc * axis.y * axis.z + axis.x * s, + oc * axis.z * axis.z + c) + ); +} + +static float3 sfPalette(float t, float3 a, float3 b, float3 c, float3 d) { + return a + b * cos(6.28318f * (c * t + d)); +} + +static float3 sfSpectrum(float n) { + return sfPalette( + 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 float2 sfRotate2D(float2 p, float a) { + return cos(a) * p + sin(a) * float2(p.y, -p.x); +} + +static float sfPReflect(thread float3 &p, float3 planeNormal, float offset) { + float localT = dot(p, planeNormal) + offset; + if (localT < 0.0f) { + p = p - (2.0f * localT) * planeNormal; + } + return sign(localT); +} + +static float sfPModPolar(thread float2 &p, float repetitions) { + float angle = 2.0f * SF_PI / repetitions; + float a = atan2(p.y, p.x) + angle / 2.0f; + float r = length(p); + float c = floor(a / angle); + a = fmod(a, angle) - angle / 2.0f; + p = float2(cos(a), sin(a)) * r; + if (abs(c) >= (repetitions / 2.0f)) { + c = abs(c); + } + return c; +} + +static ShaderdoughFairyIcosahedronData sfInitIcosahedron() { + float cospin = cos(SF_PI / 5.0f); + float scospin = sqrt(0.75f - cospin * cospin); + ShaderdoughFairyIcosahedronData data; + data.nc = float3(-0.5f, -cospin, scospin); + data.pca = normalize(float3(0.0f, scospin, cospin)); + return data; +} + +static void sfPModIcosahedron(thread float3 &p, ShaderdoughFairyIcosahedronData data) { + p = abs(p); + sfPReflect(p, data.nc, 0.0f); + p.xy = abs(p.xy); + sfPReflect(p, data.nc, 0.0f); + p.xy = abs(p.xy); + sfPReflect(p, data.nc, 0.0f); +} + +static float sfSplitPlane(float a, float b, float3 p, float3 plane) { + float split = max(sign(dot(p, plane)), 0.0f); + return mix(a, b, split); +} + +static float sfIcosahedronIndex(float3 p) { + float3 sp = sign(p); + float x = sp.x * 0.5f + 0.5f; + float y = sp.y * 0.5f + 0.5f; + float z = sp.z * 0.5f + 0.5f; + + float3 plane = float3(-1.0f - SF_PHI, -1.0f, SF_PHI); + float idx = x + y * 2.0f + z * 4.0f; + idx = sfSplitPlane(idx, 8.0f + y + z * 2.0f, p, plane * sp); + idx = sfSplitPlane(idx, 12.0f + x + y * 2.0f, p, plane.yzx * sp); + idx = sfSplitPlane(idx, 16.0f + z + x * 2.0f, p, plane.zxy * sp); + return idx; +} + +static float3 sfIcosahedronVertex(float3 p) { + float3 v = float3(SF_PHI, 1.0f, 0.0f); + float3 sp = sign(p); + float3 v1 = v.xyz * sp; + float3 v2 = v.yzx * sp; + float3 v3 = v.zxy * sp; + + float3 plane = float3(1.0f, SF_PHI, -SF_PHI - 1.0f); + float split = max(sign(dot(p, plane.xyz * sp)), 0.0f); + float3 result = mix(v2, v1, split); + plane = mix(plane.yzx * -sp, plane.zxy * sp, split); + split = max(sign(dot(p, plane)), 0.0f); + result = mix(result, v3, split); + return normalize(result); +} + +static float4 sfIcosahedronAxisDistance(float3 p, ShaderdoughFairyIcosahedronData data) { + float3 iv = sfIcosahedronVertex(p); + float3 originalIv = iv; + + float3 pn = normalize(p); + sfPModIcosahedron(pn, data); + sfPModIcosahedron(iv, data); + + float boundaryDist = dot(pn, float3(1.0f, 0.0f, 0.0f)); + float boundaryMax = dot(iv, float3(1.0f, 0.0f, 0.0f)); + boundaryDist /= boundaryMax; + + float roundDist = length(iv - pn); + float roundMax = length(iv - float3(0.0f, 0.0f, 1.0f)); + roundDist /= roundMax; + roundDist = -roundDist + 1.0f; + + float blend = 1.0f - boundaryDist; + blend = pow(blend, 6.0f); + float dist = mix(roundDist, boundaryDist, blend); + + return float4(originalIv, dist); +} + +static void sfPTwistIcosahedron(thread float3 &p, float amount, ShaderdoughFairyIcosahedronData data) { + float4 a = sfIcosahedronAxisDistance(p, data); + float3 axis = a.xyz; + float dist = a.w; + float3x3 m = sfRotationMatrix(axis, dist * amount); + p = p * m; +} + +static ShaderdoughFairyModel sfInflatedIcosahedron(float3 p, float localTime, ShaderdoughFairyIcosahedronData data) { + float idx = sfIcosahedronIndex(p); + float d = dot(p, data.pca) - 0.9f; + d = mix(d, length(p) - 0.9f, 1.0f); + + if (idx == 3.0f) { + idx = 2.0f; + } + idx /= 10.0f; + idx = fract(idx + localTime); + + ShaderdoughFairyModel result; + result.dist = d * 0.6f; + result.colour = sfSpectrum(idx); + result.id = 1.0f; + return result; +} + +static ShaderdoughFairyModel sfModel(float3 p, float localTime, ShaderdoughFairyIcosahedronData data) { + float rate = SF_PI / 6.0f; + float a = atan2(1.0f, SF_PHI + 1.0f); + + p.yz = sfRotate2D(p.yz, a); + p.yx = sfRotate2D(p.yx, localTime * 2.1f + rate); + p.yz = sfRotate2D(p.yz, a); + + float3 twistCenter = float3(0.7f, 0.0f, 0.0f); + twistCenter.yx = sfRotate2D(twistCenter.yx, localTime * 2.1f + rate); + twistCenter.yz = sfRotate2D(twistCenter.yz, a); + + p += twistCenter; + sfPTwistIcosahedron(p, 10.5f, data); + p -= twistCenter; + + p.yz = sfRotate2D(p.yz, -a); + p.xy = sfRotate2D(p.xy, -SF_PI * 0.5f); + float2 polarXY = p.xy; + sfPModPolar(polarXY, 3.0f); + p.xy = polarXY; + p.xy = sfRotate2D(p.xy, -SF_PI * 0.5f); + p.yz = sfRotate2D(p.yz, -a); + + return sfInflatedIcosahedron(p, localTime, data); +} + +static ShaderdoughFairyModel sfMap(float3 p, float localTime, ShaderdoughFairyIcosahedronData data) { + return sfModel(p, localTime, data); +} + +fragment float4 shaderdoughFairyFragment( + ShaderdoughFairyVertexOut in [[stage_in]], + constant ShaderdoughFairyUniforms &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 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); + + // Keep the underlying fairy shape fixed at the scene origin. Only the + // model's own authored deformation/rotation uses time, so the content stays + // in world space instead of following the viewer like a 2D image. + float localTime = (uniforms.time - 0.25f) * 0.5f; + ShaderdoughFairyIcosahedronData ico = sfInitIcosahedron(); + float fairyScale = 2.0f; + + float3 color = pow(float3(0.15f, 0.0f, 0.2f), float3(2.2f)); + float travel = 0.0f; + int iter = int(20.0f / SF_FUDGE_FACTOR); + + for (int i = 0; i < iter; ++i) { + if (travel > SF_MAX_TRACE_DISTANCE) { + break; + } + + float3 samplePoint = (marchOrigin + rdScene * travel) * fairyScale; + ShaderdoughFairyModel sample = sfMap(samplePoint, localTime, ico); + float h = abs(sample.dist) / fairyScale; + travel += max(SF_INTERSECTION_PRECISION, h * SF_FUDGE_FACTOR); + color += sample.colour * pow(max(0.0f, (0.02f - h)) * 19.5f, 10.0f) * 150.0f; + color += sample.colour * 0.001f * SF_FUDGE_FACTOR; + } + + color = pow(color, float3(1.0f / 1.8f)) * 1.5f; + color = pow(color, float3(1.5f)); + color *= 3.5f; + return float4(color, 1.0f); +} diff --git a/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyTypes.swift b/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyTypes.swift new file mode 100644 index 0000000..5c16400 --- /dev/null +++ b/vr-dive/Demos/ShaderdoughFairy/ShaderdoughFairyTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct ShaderdoughFairyUniforms in ShaderdoughFairyShaders.metal. +struct ShaderdoughFairyUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Shield/ShieldRenderer.swift b/vr-dive/Demos/Shield/ShieldRenderer.swift new file mode 100644 index 0000000..1948599 --- /dev/null +++ b/vr-dive/Demos/Shield/ShieldRenderer.swift @@ -0,0 +1,173 @@ +import Metal +import simd + +// ShieldRenderer.swift +// Source adaptation: Shadertoy "Shield" by @XorDev +// https://www.shadertoy.com/view/cltfRf +// +// The source is a GLSL screen-space shader. This renderer adapts the pattern to +// the app's 3D cube-portal model so the effect can be viewed from outside the +// 2 m container and from inside it as well. + +final class ShieldRenderer: VisualPatternController { + let identifier: VisualPatternKind = .shield + 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 + + // 0.5 × mesh half-extents of 1.0 = 0.5 m half-extent = 1 m cube. + private let cubeScale: Float = 0.5 + 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 = ShieldRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try ShieldRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = ShieldRenderer.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.65 + } + + 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 = ShieldUniforms( + 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 ShieldRenderer { + 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: "shieldVertex") + desc.fragmentFunction = library.makeFunction(name: "shieldFragment") + 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/Shield/ShieldShaders.metal b/vr-dive/Demos/Shield/ShieldShaders.metal new file mode 100644 index 0000000..a78783f --- /dev/null +++ b/vr-dive/Demos/Shield/ShieldShaders.metal @@ -0,0 +1,152 @@ +// ShieldShaders.metal +// "Shield" by @XorDev — https://www.shadertoy.com/view/cltfRf +// +// Faithful 3D stereo port for visionOS. +// +// The original GLSL accumulates 100 concentric sphere shells in 2D screen space: +// for(i=0; i<1; i+=.01) { p = screenNDC * i; ...sphere_distortion; ...hex; } +// +// Here each iteration analytically intersects the per-eye ray with the sphere +// shell of radius i. Because left/right eye positions differ, each shell is +// sampled at a slightly different 3D point — producing real stereo parallax. +// The hex formula, sphere distortion, z-weighting, and tanh tonemap are +// unchanged from the original. + +#include +using namespace metal; + +struct ShieldUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct ShieldVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float3 SH_BOX_HALF = float3(1.0f); + +static float2 shBoxIntersect(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 shRaySphereHit(float3 ro, float3 rd, float radius) { + float b = dot(ro, rd); + float c = dot(ro, ro) - radius * radius; + float h = b * b - c; + if (h < 0.0f) { + return -1.0f; + } + + float s = sqrt(h); + float tNear = -b - s; + float tFar = -b + s; + if (tNear > 1.0e-4f) { + return tNear; + } + if (tFar > 1.0e-4f) { + return tFar; + } + return -1.0f; +} + +vertex ShieldVertexOut shieldVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant ShieldUniforms &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; + + ShieldVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment shader — rebuild the original shell stack as actual 3D shells +// centered on the cube. Each eye now traces its own ray through the shell +// volume, so stereo disparity comes from real 3D hit points rather than a +// shared screen-space UV projection. +// --------------------------------------------------------------------------- + +fragment float4 shieldFragment( + ShieldVertexOut in [[stage_in]], + constant ShieldUniforms &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 camWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 eye = (camWorld - center) / scale; + float3 surfacePos = (in.worldPos - center) / scale; + float3 rd = normalize(surfacePos - eye); + + bool insideOuter = all(abs(eye) < SH_BOX_HALF - 1.0e-3f); + float2 tOuter = shBoxIntersect(eye, rd, SH_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 ro = eye; + const float3 fixedRight = float3(1.0f, 0.0f, 0.0f); + const float3 fixedUp = float3(0.0f, 1.0f, 0.0f); + + float t = uniforms.time; + float4 O = float4(0.0f); + + for (int n = 1; n <= 100; n++) { + float i = float(n) * 0.01f; + float hitT = shRaySphereHit(ro, rd, i); + if (hitT < max(tStart, 0.0f)) { + continue; + } + + float3 hit = ro + rd * hitT; + float2 p = float2(dot(hit, fixedRight), dot(hit, fixedUp)) / max(i, 1.0e-4f); + + float z = max(1.0f - dot(p, p), 0.0f); + if (z <= 0.0f) { + continue; + } + p /= 0.2f + sqrt(z) * 0.3f; + + p.x = p.x / 0.9f + t; + p.y += fract(ceil(p.x) * 0.5f) + t * 0.2f; + + float2 v = abs(fract(p) - 0.5f); + float hexDist = abs(max(v.x * 1.5f + v, v + v).y - 1.0f) + + 0.1f - i * 0.09f; + + O += float4(2.0f, 3.0f, 5.0f, 1.0f) / 2000.0f * z / hexDist; + } + + O = tanh(O * O); + return float4(O.rgb, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Shield/ShieldTypes.swift b/vr-dive/Demos/Shield/ShieldTypes.swift new file mode 100644 index 0000000..18ec9e5 --- /dev/null +++ b/vr-dive/Demos/Shield/ShieldTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct ShieldUniforms in ShieldShaders.metal. +struct ShieldUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/SineBud/SineBudRenderer.swift b/vr-dive/Demos/SineBud/SineBudRenderer.swift new file mode 100644 index 0000000..a139815 --- /dev/null +++ b/vr-dive/Demos/SineBud/SineBudRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// SineBudRenderer.swift +// +// Cube-container adaptation of ShaderToy "Sine bud" (Mcl3Wn). +// 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 SineBudRenderer: VisualPatternController { + let identifier: VisualPatternKind = .sineBud + 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 = SineBudRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SineBudRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SineBudRenderer.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 = SineBudUniforms( + 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 SineBudRenderer { + 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: "sineBudVertex") + desc.fragmentFunction = library.makeFunction(name: "sineBudFragment") + 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/SineBud/SineBudShaders.metal b/vr-dive/Demos/SineBud/SineBudShaders.metal new file mode 100644 index 0000000..47ea4d7 --- /dev/null +++ b/vr-dive/Demos/SineBud/SineBudShaders.metal @@ -0,0 +1,201 @@ +// SineBudShaders.metal +// Adapted from ShaderToy "Sine bud". +// Source: https://www.shadertoy.com/view/Mcl3Wn +// +// Metal adaptation notes: +// - The original shader is a compact screen-space ray marcher using macros. +// This version reconstructs the real per-eye world ray, intersects it with a +// 2 m cube container, and marches from the visible cube surface or from the +// eye when the viewer is inside the cube. +// - The iterative field continues beyond the entry plane, so the simulated bud +// is not clipped to the cube volume. +// - GLSL macros for rotation, polar transforms and wave evaluation are expanded +// into explicit Metal helper functions. + +#include +using namespace metal; + +struct SineBudUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SineBudVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float SB_PI = 3.1416f; +static constant float SB_PI_2 = 1.5708f; +static constant float3 SB_BOX_HALF = float3(1.0f); +static constant float SB_TRACE_EPSILON = 0.0015f; +static constant float SB_HIT_EPSILON = 0.0025f; +static constant int SB_STEPS = 280; +static constant float SB_MAX_DIST = 28.0f; +static constant float SB_SCENE_SCALE = 2.8f; + +static float3 sbPalette(float t) { + return 0.55f + 0.45f * cos(6.2831853f * (float3(0.02f, 0.18f, 0.42f) + t * float3(0.9f, 0.7f, 0.55f))); +} + +vertex SineBudVertexOut sineBudVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SineBudUniforms &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; + + SineBudVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 sbRotate2D(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 float2 sbFaceUV(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 sbBoxIntersect(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 sbWave(float3 p, uint axisA, uint axisB) { + float px = p.x; + float2 pair; + if (axisA == 1u && axisB == 2u) { + pair = p.yz; + } else if (axisA == 2u && axisB == 1u) { + pair = p.zy; + } else if (axisA == 0u && axisB == 2u) { + pair = p.xz; + } else if (axisA == 2u && axisB == 0u) { + pair = p.zx; + } else if (axisA == 0u && axisB == 1u) { + pair = p.xy; + } else { + pair = p.yx; + } + float2 target = abs(sin(px + float2(0.0f, SB_PI_2))); + return length(float3(pair - target, 0.0f)); +} + +static float3 sbPolar(float3 v) { + return float3(atan2(v.x, v.y), length(v.xy), sqrt(abs(v.z))); +} + +fragment float4 sineBudFragment( + SineBudVertexOut in [[stage_in]], + constant SineBudUniforms &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) < SB_BOX_HALF - 1.0e-3f); + float2 tBox = sbBoxIntersect(eye, rd, SB_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 + SB_TRACE_EPSILON)) * SB_SCENE_SCALE; + + float t = uniforms.time / 5.0f; + float2 m = -float2(t - SB_PI_2 * 0.5f, 0.6f); + + float d = 0.0f; + float s = 1.0f; + float3 k = float3(0.0f); + float3 c = float3(0.0f); + bool hitBud = false; + + for (int step = 0; step < SB_STEPS; ++step) { + float3 p = ro + rd * d; + p.yz = sbRotate2D(p.yz, m.y); + p.xz = sbRotate2D(p.xz, m.x); + p = abs(p) - cos(t + SB_PI) * 0.5f - 0.5f; + + float axisSphereX = length(abs(p) - float3(1.0f, 0.0f, 0.0f)) - 0.07f; + float axisSphereY = length(abs(p) - float3(0.0f, 1.0f, 0.0f)) - 0.07f; + float axisSphereZ = length(abs(p) - float3(0.0f, 0.0f, 1.0f)) - 0.07f; + s = min(s, min(axisSphereX, min(axisSphereY, axisSphereZ))); + + float3 q = p; + p = sbPolar(q); + k.x = min(sbWave(p, 1u, 2u), sbWave(p, 2u, 1u)); + p = sbPolar(q.yzx); + k.y = min(sbWave(p, 1u, 2u), sbWave(p, 2u, 1u)); + p = sbPolar(q.zxy); + k.z = min(sbWave(p, 1u, 2u), sbWave(p, 2u, 1u)); + + s = min(s, min(k.z, min(k.x, k.y))); + + float axisMin = min(axisSphereX, min(axisSphereY, axisSphereZ)); + float lineGlow = exp(-24.0f * max(s, 0.0f)); + float nodeGlow = exp(-30.0f * max(axisMin, 0.0f)); + float3 tint = sbPalette(0.07f * d + 0.35f * (k.x + k.y + k.z)); + c += tint * (0.014f * lineGlow + 0.011f * nodeGlow); + + if (s < SB_HIT_EPSILON || d > SB_MAX_DIST) { + hitBud = s < SB_HIT_EPSILON; + break; + } + d += clamp(s * 0.18f, 0.004f, 0.08f); + } + + if (hitBud) { + float3 surface = max(cos(d * SB_PI * 2.0f) - s * sqrt(max(d, 0.0f)) - k, 0.0f); + c += surface; + c += surface.brg * 0.8f; + c += surface * surface * 0.6f; + } + float3 color = 1.0f - exp(-2.0f * c); + + float2 q = sbFaceUV(hit); + float vignette = 1.0f - 0.18f * dot(q * 2.0f - 1.0f, q * 2.0f - 1.0f); + color *= vignette; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/SineBud/SineBudTypes.swift b/vr-dive/Demos/SineBud/SineBudTypes.swift new file mode 100644 index 0000000..aed522a --- /dev/null +++ b/vr-dive/Demos/SineBud/SineBudTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct SineBudUniforms in SineBudShaders.metal. +struct SineBudUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesRenderer.swift b/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesRenderer.swift new file mode 100644 index 0000000..5c7f8bd --- /dev/null +++ b/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// SlicesInMarblesRenderer.swift +// Cube-container adaptation of ShaderToy "slices in marbles" (tdXGWM). + +final class SlicesInMarblesRenderer: VisualPatternController { + let identifier: VisualPatternKind = .slicesInMarbles + 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 = SlicesInMarblesRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SlicesInMarblesRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SlicesInMarblesRenderer.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 = SlicesInMarblesUniforms( + 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 SlicesInMarblesRenderer { + 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: "slicesInMarblesVertex") + desc.fragmentFunction = library.makeFunction(name: "slicesInMarblesFragment") + 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/SlicesInMarbles/SlicesInMarblesShaders.metal b/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesShaders.metal new file mode 100644 index 0000000..fc8a52f --- /dev/null +++ b/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesShaders.metal @@ -0,0 +1,211 @@ +// SlicesInMarblesShaders.metal +// "slices in marbles" — cube-container adaptation of ShaderToy tdXGWM. +// Source: https://www.shadertoy.com/view/tdXGWM +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Adaptation notes: +// - The original shader renders a glass marble with milky refracted slices +// using a synthetic screen camera and iChannel0 environment texture. +// - This version reconstructs a real per-eye ray from the 2 m cube container, +// supports starting from the cube surface or from inside the cube, and +// replaces the external texture with a procedural environment. + +#include +using namespace metal; + +struct SlicesInMarblesUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SlicesInMarblesVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant int SIM_DETAIL = 5; +static constant int SIM_INNER_DEPTH = 64; +static constant float SIM_COLOR_CONTRAST = 45.0f; +static constant float SIM_MILKY_LIGHT = 80.0f; +static constant float SIM_OPACITY_OF_COLOR = 1.0f; +static constant float SIM_ZOOM = 1.0f; +static constant float SIM_SPHERE_RADIUS = 2.0f; +static constant float3 SIM_BOX_HALF = float3(1.0f); + +vertex SlicesInMarblesVertexOut slicesInMarblesVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SlicesInMarblesUniforms &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; + + SlicesInMarblesVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 simCSqr(float2 a) { + return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y); +} + +static float2 simRot(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 float2 simSphereIntersect(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 float3 simMap(float3 p) { + float res = 0.0f; + float3 c = p; + for (int i = 0; i < SIM_DETAIL; ++i) { + p = 0.7f * abs(p) / max(dot(p, p), 1.0e-4f) - 0.7f; + p.yz = simCSqr(p.yz); + p = p.zxy; + res += exp(-19.0f * abs(dot(p, c))) + 0.02f; + } + + float3 normP = normalize(select(float3(0.0f, 0.0f, 1.0f), p, dot(p, p) > 1.0e-6f)); + return res * SIM_COLOR_CONTRAST * 0.013f * (normP + (1.0f - SIM_OPACITY_OF_COLOR) * float3(1.0f)); +} + +static float3 simEnvironment(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.28f, 0.45f, -0.84f))), 0.0f), 56.0f); + float shimmer = 0.5f + 0.5f * sin((dir.x - dir.z) * 12.0f + time * 0.2f); + float3 sky = mix(float3(0.012f, 0.02f, 0.04f), float3(0.12f, 0.18f, 0.30f), skyMix); + sky += float3(0.12f, 0.16f, 0.22f) * horizon * shimmer * 0.30f; + sky += float3(1.0f, 0.95f, 0.9f) * sun; + return clamp(sky, 0.0f, 2.0f); +} + +static float2 simBoxIntersect(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 simFaceUV(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 simRaymarch(float3 ro, float3 rd, float2 tminmax) { + float t = tminmax.x; + const float dt = 0.02f; + float3 col = float3(0.0f); + float3 c = float3(0.0f); + + for (int i = 0; i < SIM_INNER_DEPTH; ++i) { + t += dt * exp(-2.0f * length(c)); + if (t > tminmax.y) { + break; + } + + float3 pos = refract(ro, (ro + t * rd) / 0.7f, -0.012f); + c = simMap(pos); + float gr = SIM_MILKY_LIGHT * 0.013824f / float(SIM_INNER_DEPTH); + col = 0.995f * col + 0.09f * c + float3(gr); + } + + return col; +} + +fragment float4 slicesInMarblesFragment( + SlicesInMarblesVertexOut in [[stage_in]], + constant SlicesInMarblesUniforms &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) < SIM_BOX_HALF - 1.0e-3f); + float2 tOuter = simBoxIntersect(eye, rd, SIM_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)) * (SIM_ZOOM * 4.0f); + + float orbitX = 0.1f * uniforms.time; + float orbitY = 0.12f * sin(uniforms.time * 0.11f); + ro.yz = simRot(ro.yz, orbitY); + ro.xz = simRot(ro.xz, orbitX); + + float3 marchDir = normalize(rd); + marchDir.yz = simRot(marchDir.yz, orbitY); + marchDir.xz = simRot(marchDir.xz, orbitX); + + float2 tmm = simSphereIntersect(ro, marchDir, float4(0.0f, 0.0f, 0.0f, SIM_SPHERE_RADIUS)); + float3 col; + if (tmm.x < 0.0f && tmm.y < 0.0f) { + col = simEnvironment(marchDir, uniforms.time); + } else { + float tNear = max(tmm.x, 0.0f); + float tFar = max(tmm.y, tNear); + col = simRaymarch(ro, marchDir, float2(tNear, tFar)); + + float tSurface = (tmm.x > 0.0f) ? tmm.x : tmm.y; + float3 reflectedNormal = (ro + tSurface * marchDir) / SIM_SPHERE_RADIUS; + float3 reflected = reflect(marchDir, reflectedNormal); + float fre = pow(0.5f + clamp(dot(reflected, marchDir), 0.0f, 1.0f), 3.0f) * 1.3f; + col += simEnvironment(reflected, uniforms.time) * fre; + } + + col = 0.5f * log(1.0f + col); + col = clamp(col, 0.0f, 1.0f); + + float2 faceUV = simFaceUV(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/SlicesInMarbles/SlicesInMarblesTypes.swift b/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesTypes.swift new file mode 100644 index 0000000..e84945a --- /dev/null +++ b/vr-dive/Demos/SlicesInMarbles/SlicesInMarblesTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct SlicesInMarblesUniforms in +/// SlicesInMarblesShaders.metal. +struct SlicesInMarblesUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Snake3D/PLAN.md b/vr-dive/Demos/Snake3D/PLAN.md new file mode 100644 index 0000000..b8f4d62 --- /dev/null +++ b/vr-dive/Demos/Snake3D/PLAN.md @@ -0,0 +1,217 @@ +# 3D 贪食蛇 (Snake3D) - 开发计划 + +## 项目概述 + +在 VisionOS 沉浸式空间中实现的 3D 贪食蛇游戏。核心视觉机制:**蛇头始终朝向玩家正前方**,玩家按转向键时,游戏世界做反向旋转,造成"蛇一直向前冲、空间在翻滚"的沉浸感。 + +--- + +## 核心机制:世界旋转视角 + +### 原理 + +- 蛇在逻辑 3D 网格中记录真实方向,但渲染时蛇头坐标系始终对齐摄像机前方(-Z 轴) +- 每次转向,维护一个**累积世界旋转四元数** `worldOrientation: simd_quatf` +- 渲染所有几何体(蛇身、食物、边界框)时,先用 `worldOrientation` 旋转世界坐标,再做 ViewProjection 变换 +- 转向时,用 `simd_slerp` 做平滑插值,动画时长约 0.3 秒 + +### 转向与世界旋转对应关系 + +| PS 手柄按键 | 蛇的逻辑转向(世界坐标) | 世界渲染反向旋转 | +| ----------- | ------------------------ | -------------------- | +| Dpad Up | 蛇向当前相对"上"(+Y)转 | 绕局部 X 轴旋转 −90° | +| Dpad Down | 蛇向当前相对"下"(−Y)转 | 绕局部 X 轴旋转 +90° | +| Dpad Left | 蛇向当前相对"左"(−X)转 | 绕局部 Y 轴旋转 +90° | +| Dpad Right | 蛇向当前相对"右"(+X)转 | 绕局部 Y 轴旋转 −90° | +| □ (Square) | 绕前进轴向左翻滚 | 绕局部 Z 轴旋转 −90° | +| ○ (Circle) | 绕前进轴向右翻滚 | 绕局部 Z 轴旋转 +90° | + +> 注意:每次转向都是相对于蛇当前的朝向坐标系,而非世界绝对轴。旋转轴需在蛇的局部坐标系中计算后转换到世界空间。 + +### 蛇头位置的锚定 + +- 蛇头在渲染空间始终位于固定坐标(如略微偏前,距相机约 0.6m 处) +- 世界中所有坐标 = `worldRotation * (worldPos - snakeHeadWorldPos)` + 渲染锚点 +- 这样旋转轴心固定在蛇头位置,转向时空间绕蛇头翻转 + +--- + +## 文件结构 + +``` +vr-dive/Demos/Snake3D/ +├── PLAN.md ← 本文件 +├── Snake3DTypes.swift # 数据类型 & GPU 结构体 +├── Snake3DGameLogic.swift # 游戏逻辑(网格、移动、碰撞) +├── Snake3DRenderer.swift # Metal 渲染器(VisualPatternController) +└── Snake3DShaders.metal # 顶点/片段着色器 +``` + +--- + +## 各文件职责详述 + +### Snake3DTypes.swift + +```swift +// 6 个方向枚举 +enum SnakeDirection { case posX, negX, posY, negY, posZ, negZ } + +// 游戏状态(纯值类型,线程安全拷贝) +struct Snake3DState { + var segments: [SIMD3] // 蛇体,index 0 = 头部 + var direction: SnakeDirection + var foods: [SIMD3] // 同时存在的食物列表 + var score: Int + var isGameOver: Bool + var gridSize: Int // 立方体网格边长(默认 20) +} + +// GPU 实例缓冲区(蛇身节段) +struct SnakeSegmentInstance { + var position: SIMD3 + var color: SIMD4 // 头部亮绿,尾部渐暗 + var scale: Float +} + +// GPU 食物实例 +struct FoodInstance { + var position: SIMD3 + var phase: Float // 动画相位(脉冲) +} + +// CPU→GPU Scene Uniforms +struct Snake3DSceneUniforms { + var worldRotation: float4x4 // 世界旋转矩阵(累积) + var anchorOffset: SIMD3 // 蛇头锚定偏移 + var time: Float + var layerCount: UInt32 +} +``` + +### Snake3DGameLogic.swift + +- **网格**:20×20×20,坐标范围 [0, 19]³ +- **蛇体存储**:`[SIMD3]`,每次前进 prepend 新头,pop 尾(若未吃食物) +- **食物**:随机生成,最多 3 个同时存在,不与蛇身重叠 +- **碰撞检测**: + - 撞自身:新头坐标在 `segments` 中命中 → isGameOver + - 撞墙:是否环绕由 `wallMode` 参数控制(计划支持环绕 / 死亡两种) +- **移动计时**:每 `moveInterval` 秒前进一格,随分数加速 +- **转向处理**:不允许与当前方向相反(180° 调头) + +### Snake3DRenderer.swift + +继承 `VisualPatternController`,职责: + +1. **世界旋转状态** + - `currentWorldQuat: simd_quatf`(当前渲染四元数) + - `targetWorldQuat: simd_quatf`(目标四元数,转向时更新) + - 每帧用 `simd_slerp` 插值,旋转动画 0.3s 完成 + - 旋转轴心偏移:将蛇头锚定在屏幕前方固定点 + +2. **输入处理**(读取 `GameManager` D-pad 状态) + - Dpad → `gameLogic.handleInput(...)` 改变蛇方向 + - 同步更新 `targetWorldQuat`(反向旋转) + - 按键防抖:`buttonDelay = 0.3s`(≥ 移动间隔) + +3. **渲染帧**(`draw(drawable:commandBuffer:uniforms:)` 协议方法) + - 更新 `Snake3DSceneUniforms`(worldRotation, time, layerCount) + - 上传蛇身 Instance Buffer(最多 200 节) + - 上传食物 Instance Buffer(最多 3 个) + - Draw call:蛇身(instanced cube)→ 食物(instanced sphere/cube)→ 边界框(line strip) + +4. **边界框** + - 20×20×20 半透明线框,帮助玩家感知空间 + - 随世界旋转一同变换 + +### Snake3DShaders.metal + +```metal +// 顶点着色器(蛇身 + 食物共用) +vertex SnakeVertexOut snake3DVertexShader(...) +// 应用 worldRotation * (pos - headAnchor) + screenAnchor +// 再乘 ViewProjection + +// 片段着色器(蛇身) +fragment float4 snake3DBodyFragment(...) +// 头部 = 明亮绿色,尾部颜色根据实例 index 衰减至深色 + +// 片段着色器(食物) +fragment float4 snake3DFoodFragment(...) +// 发光脉冲:sin(time * 3 + phase) * 0.3 + 0.7 调制亮度 + +// 边界框着色器 +vertex/fragment snake3DBorderShader(...) +// 低透明度白色线框 +``` + +--- + +## 与现有系统集成 + +### PatternSelection.swift + +在 `VisualPatternKind` 枚举中添加: + +```swift +case snake3D +// displayName: "3D 贪食蛇" +``` + +### Renderer.swift + +仿照 `addTetris3D` 方法,添加 `addSnake3D(...)` 静态方法: + +```swift +static func addSnake3D( + to controllers: inout [VisualPatternKind: VisualPatternController], + device: MTLDevice, library: MTLLibrary, + maxViewCount: Int, gameManager: GameManager +) { + controllers[.snake3D] = Snake3DRenderer(...) +} +``` + +--- + +## 游戏参数(默认值) + +| 参数 | 值 | 说明 | +| ------------ | -------------------------------- | ------------------------------ | +| 网格大小 | 20×20×20 | 约 2m³ 的物理空间 | +| 格子步长 | 0.1m | 每格 10cm | +| 方块大小 | 0.09m | 方块不占满格子,留 1cm 间隙 | +| 蛇头锚点 | (0, 0, −0.6m) | 相对相机的固定渲染位置 | +| 初始蛇长 | 5 节 | | +| 初始移动间隔 | 0.4s/格 | | +| 加速规则 | 每 5 分提速 5% | 上限 0.15s/格 | +| 同时食物数 | 3 个 | | +| 旋转动画时长 | 0.3s | slerp 插值 | +| 边界模式 | 无限空间,食物只出现在原始区域内 | 蛇出界不死,食物限制在 [0,19]³ | +| 蛇身渲染 | 立方体(方的) | | +| 翻滚控制 | □ 左翻 / ○ 右翻 | 绕前进轴 Z 轴旋转 | +| 重开按键 | Options | | + +--- + +## 待确认问题 + +1. **边界模式**:撞墙死亡 vs 环绕穿越(Pac-Man 风格)? +2. **旋转动画时长**:0.3s 是否舒适?是否需要防晕动病(降低旋转速度/增加参考线)? +3. **游戏空间大小**:20³ 格 × 4cm = 80cm 边长,是否合适? +4. **蛇速**:初始 0.4s/格,是否合适? +5. **食物数量**:同时 3 个,是否合适? +6. **蛇身渲染**:纯立方体节段 vs 带圆角/连接处的光滑外观? +7. **游戏重置**:按哪个按键重开游戏(如 Options 键)? + +--- + +## 开发顺序 + +- [ ] 1. `Snake3DTypes.swift` — 类型定义 & GPU 结构体 +- [ ] 2. `Snake3DGameLogic.swift` — 游戏逻辑(不含渲染) +- [ ] 3. `Snake3DShaders.metal` — Metal 着色器 +- [ ] 4. `Snake3DRenderer.swift` — 渲染器 & 输入处理 +- [ ] 5. `PatternSelection.swift` — 注册新模式 +- [ ] 6. `Renderer.swift` — 集成到主渲染循环 diff --git a/vr-dive/Demos/Snake3D/Snake3DGameLogic.swift b/vr-dive/Demos/Snake3D/Snake3DGameLogic.swift new file mode 100644 index 0000000..2b1cf1c --- /dev/null +++ b/vr-dive/Demos/Snake3D/Snake3DGameLogic.swift @@ -0,0 +1,194 @@ +import Foundation +import simd + +final class Snake3DGameLogic { + + // MARK: - Public State + private(set) var state: Snake3DState + + // MARK: - Constants + static let maxFoodCount = 4096 + static let initialMoveInterval: TimeInterval = 0.32 + static let minMoveInterval: TimeInterval = 0.15 + static let speedUpPerScore = 5 // every N score points, speed up + static let speedUpFactor: Double = 0.985 + + // MARK: - Private + private var lastMoveTime: TimeInterval = 0 + private var moveInterval: TimeInterval = initialMoveInterval + private var hasStartedMoving = false + var isBoosting: Bool = false + + // MARK: - Init + + init() { + state = Snake3DState() + spawnFoodIfNeeded() + } + + // MARK: - Game Loop + + func update(currentTime: TimeInterval) { + guard !state.isGameOver else { return } + if !hasStartedMoving { + hasStartedMoving = true + lastMoveTime = currentTime + return + } + + while currentTime - lastMoveTime >= moveInterval * (isBoosting ? 0.5 : 1.0) { + lastMoveTime += moveInterval * (isBoosting ? 0.5 : 1.0) + step() + if state.isGameOver { + break + } + } + } + + // MARK: - Input + + /// Called by renderer when a direction button is pressed. + /// `newDirection` is expressed in the snake's *current* local coordinate frame. + /// The game logic just records the new world axis direction. + func requestTurn(to newDirection: SnakeDirection) { + guard newDirection != state.direction, + newDirection != state.direction.opposite + else { return } + state.pendingDirection = newDirection + } + + func reset() { + state = Snake3DState() + lastMoveTime = 0 + moveInterval = Self.initialMoveInterval + hasStartedMoving = false + spawnFoodIfNeeded() + } + + // MARK: - Step + + private func step() { + // Apply pending turn + if let pending = state.pendingDirection { + state.direction = pending + state.pendingDirection = nil + } + + let head = state.segments[0] + let newHead = head &+ state.direction.delta + + // Collision: self + if state.segments.contains(newHead) { + state.isGameOver = true + return + } + + // Move: prepend new head + state.segments.insert(newHead, at: 0) + + // Check food + if let foodIndex = state.foods.firstIndex(of: newHead) { + // Eat food + state.foods.remove(at: foodIndex) + state.score += 1 + state.pendingGrow += 2 // grow 2 extra segments per food + updateMoveInterval() + } + + // Remove tail unless growing + if state.pendingGrow > 0 { + state.pendingGrow -= 1 + } else { + state.segments.removeLast() + } + + spawnFoodIfNeeded() + } + + // MARK: - Food + + private func spawnFoodIfNeeded() { + let g = Snake3DState.gridSize + var attempts = 0 + while state.foods.count < Self.maxFoodCount, attempts < 1000 { + attempts += 1 + let candidate = SIMD3( + Int.random(in: 0.. [(gridPos: SIMD3, normalizedIndex: Float)] { + let count = max(state.segments.count, 1) + let denominator = Float(max(count - 1, 1)) + return state.segments.enumerated().map { i, pos in + (pos, Float(i) / denominator) + } + } + + func getInterpolatedSegmentPositions(currentTime: TimeInterval) -> [( + gridPos: SIMD3, normalizedIndex: Float + )] { + let count = max(state.segments.count, 1) + let denominator = Float(max(count - 1, 1)) + let alpha = interpolationAlpha(currentTime: currentTime) + let predicted = predictedSegmentsForCurrentDirection() + + return state.segments.enumerated().map { index, pos in + let start = SIMD3(Float(pos.x), Float(pos.y), Float(pos.z)) + let targetInt = predicted[min(index, predicted.count - 1)] + let target = SIMD3(Float(targetInt.x), Float(targetInt.y), Float(targetInt.z)) + let interpolated = simd_mix(start, target, SIMD3(repeating: alpha)) + return (interpolated, Float(index) / denominator) + } + } + + func getInterpolatedHeadPosition(currentTime: TimeInterval) -> SIMD3 { + guard let head = getInterpolatedSegmentPositions(currentTime: currentTime).first?.gridPos else { + return SIMD3( + Float(Snake3DState.gridSize / 2), Float(Snake3DState.gridSize / 2), + Float(Snake3DState.gridSize / 2)) + } + return head + } + + func getFoodPositions() -> [SIMD3] { + return state.foods + } + + private func interpolationAlpha(currentTime: TimeInterval) -> Float { + guard hasStartedMoving, moveInterval > 0 else { return 0 } + let progress = max(0, min(1, (currentTime - lastMoveTime) / moveInterval)) + return Float(progress) + } + + private func predictedSegmentsForCurrentDirection() -> [SIMD3] { + guard let head = state.segments.first else { return [] } + let nextHead = head &+ state.direction.delta + var predicted = [nextHead] + + if state.segments.count > 1 { + predicted.append(contentsOf: state.segments.dropLast()) + } + + return predicted + } +} diff --git a/vr-dive/Demos/Snake3D/Snake3DRenderer.swift b/vr-dive/Demos/Snake3D/Snake3DRenderer.swift new file mode 100644 index 0000000..5dd83a6 --- /dev/null +++ b/vr-dive/Demos/Snake3D/Snake3DRenderer.swift @@ -0,0 +1,796 @@ +import Metal +import simd + +// MARK: - Pipeline creation helpers (private typealias) +private typealias MeshBuffers = (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) + +// MARK: - Snake3DRenderer + +final class Snake3DRenderer: VisualPatternController { + + // MARK: Protocol + let identifier: VisualPatternKind = .snake3D + let preferredClearColor = MTLClearColor(red: 0.02, green: 0.02, blue: 0.05, alpha: 1) + + // MARK: Metal + private let device: MTLDevice + private let maxViewCount: Int + private let bodyPipelineState: MTLRenderPipelineState + private let foodPipelineState: MTLRenderPipelineState + private let borderPipelineState: MTLRenderPipelineState + private let depthStencilState: MTLDepthStencilState + private let transparentDepthStencilState: MTLDepthStencilState + + // Shared cube geometry + private let cubeMesh: MeshBuffers + + // Instance buffers + private var bodyBuffer: MTLBuffer + private var foodBuffer: MTLBuffer + private var guideBuffer: MTLBuffer + private let maxSegments = 512 + private let maxFoods = 4096 + private let maxGuideInstances = Snake3DState.gridSize * 8 + + // Border line buffer (static) + private var borderBuffer: MTLBuffer! + private var borderInstanceCount: Int = 0 + private var guideInstanceCount: Int = 0 + + // MARK: Game + private let gameLogic: Snake3DGameLogic + private weak var gameManager: GameManager? + + // MARK: World Rotation State + // The world quaternion transforms all geometry so the snake head always + // points toward the camera (-Z). When the player turns, we compose an + // *inverse* rotation onto worldQuat so the rendered world rotates opposite + // to the snake's direction change. + private var currentWorldQuat: simd_quatf = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) // identity + private var rotationStartQuat: simd_quatf = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + private var targetWorldQuat: simd_quatf = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + private var rotationProgress: Float = 1.0 // 1.0 = done + private let rotationDuration: Float = 1.2 + + // MARK: Input debounce + private var lastDpadTime: TimeInterval = 0 + private var lastRollTime: TimeInterval = 0 + private var lastOptionsTime: TimeInterval = 0 + private let dpadDelay: TimeInterval = 0.31 // slightly above move interval + private let rollDelay: TimeInterval = 0.31 + private let optionsDelay: TimeInterval = 0.5 + + // MARK: Fixed anchor in view space: slightly in front of origin + private let headAnchor = SIMD3(0.0, 0.0, -0.6) + private let displayRight = SIMD3(1.0, 0.0, 0.0) + private let displayUp = SIMD3(0.0, 1.0, 0.0) + private let displayForward = SIMD3(0.0, 0.0, -1.0) + + // MARK: Last update time + private var lastUpdateTime: TimeInterval = 0 + // Game-local time: advances only when updateSimulation runs (pauses when paused) + private var gameTime: TimeInterval = 0 + + // MARK: - Init + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int, gameManager: GameManager) { + self.device = device + self.maxViewCount = max(1, maxViewCount) + self.gameManager = gameManager + self.gameLogic = Snake3DGameLogic() + + cubeMesh = Snake3DRenderer.makeCubeGeometry(device: device) + + bodyBuffer = device.makeBuffer( + length: MemoryLayout.stride * 512, + options: .storageModeShared)! + + foodBuffer = device.makeBuffer( + length: MemoryLayout.stride * maxFoods, + options: .storageModeShared)! + + guideBuffer = device.makeBuffer( + length: MemoryLayout.stride * maxGuideInstances, + options: .storageModeShared)! + + bodyPipelineState = try! Snake3DRenderer.makeBodyPipeline( + device: device, library: library, maxViewCount: max(1, maxViewCount)) + foodPipelineState = try! Snake3DRenderer.makeFoodPipeline( + device: device, library: library, maxViewCount: max(1, maxViewCount)) + borderPipelineState = try! Snake3DRenderer.makeBorderPipeline( + device: device, library: library, maxViewCount: max(1, maxViewCount)) + depthStencilState = Snake3DRenderer.makeDepthStencilState(device: device) + transparentDepthStencilState = Snake3DRenderer.makeTransparentDepthStencilState(device: device) + + buildBorderGeometry() + updateGuideGeometry() + } + + // MARK: - VisualPatternController Protocol + + func updateSimulation(_ context: PatternSimulationContext) { + let currentTime = TimeInterval(context.time) + let deltaTime = Float(max(0, currentTime - lastUpdateTime)) + lastUpdateTime = currentTime + gameTime += TimeInterval(deltaTime) + + // Advance rotation interpolation + if rotationProgress < 1.0 { + rotationProgress = min(rotationProgress + deltaTime / rotationDuration, 1.0) + let easedProgress = smoothRotationProgress(rotationProgress) + currentWorldQuat = simd_slerp(rotationStartQuat, targetWorldQuat, easedProgress) + } + + // Handle input + processInput(currentTime: currentTime) + + // Update game tick + gameLogic.update(currentTime: gameTime) + + // Upload instance data + uploadBodyInstances() + uploadFoodInstances() + updateGuideGeometry() + } + + func resetToInitialState() { + gameLogic.reset() + gameTime = 0 + currentWorldQuat = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + rotationStartQuat = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + targetWorldQuat = simd_quatf(ix: 0, iy: 0, iz: 0, r: 1) + rotationProgress = 1.0 + lastUpdateTime = 0 + } + + func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) { + encoder.setDepthStencilState(depthStencilState) + encoder.setCullMode(.none) + encoder.setFrontFacing(.counterClockwise) + + context.applyViewConfiguration(on: encoder) + + var uniforms = makeSceneUniforms( + time: context.time, + layerCount: UInt32(context.viewData.viewCount)) + var viewMatrices = context.viewData.viewProjectionMatrices + if viewMatrices.isEmpty { viewMatrices = [matrix_identity_float4x4] } + + // Draw opaque helper geometry first so the snake remains the primary visual. + drawBorder(encoder: encoder, uniforms: &uniforms, viewMatrices: viewMatrices) + drawGuides(encoder: encoder, uniforms: &uniforms, viewMatrices: viewMatrices) + + // Draw snake body + let segCount = gameLogic.state.segments.count + if segCount > 0 { + encoder.setRenderPipelineState(bodyPipelineState) + encoder.setVertexBuffer(cubeMesh.vertexBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(bodyBuffer, offset: 0, index: 1) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 2) + viewMatrices.withUnsafeBytes { raw in + if let base = raw.baseAddress, raw.count > 0 { + encoder.setVertexBytes(base, length: raw.count, index: 3) + } + } + encoder.setFragmentBytes( + &uniforms, length: MemoryLayout.stride, index: 0) + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: cubeMesh.indexCount, + indexType: .uint16, + indexBuffer: cubeMesh.indexBuffer, + indexBufferOffset: 0, + instanceCount: min(segCount, maxSegments)) + } + + // Draw food + let foodCount = gameLogic.state.foods.count + if foodCount > 0 { + encoder.setRenderPipelineState(foodPipelineState) + encoder.setVertexBuffer(cubeMesh.vertexBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(foodBuffer, offset: 0, index: 1) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 2) + viewMatrices.withUnsafeBytes { raw in + if let base = raw.baseAddress, raw.count > 0 { + encoder.setVertexBytes(base, length: raw.count, index: 3) + } + } + encoder.setFragmentBytes( + &uniforms, length: MemoryLayout.stride, index: 0) + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: cubeMesh.indexCount, + indexType: .uint16, + indexBuffer: cubeMesh.indexBuffer, + indexBufferOffset: 0, + instanceCount: min(foodCount, maxFoods)) + } + } + + // MARK: - Input Processing + + private func processInput(currentTime: TimeInterval) { + guard let manager = gameManager else { return } + let input = manager.getTetrisInput() + + // Options button = reset + if input.buttonTriangle && currentTime - lastOptionsTime > optionsDelay { + lastOptionsTime = currentTime + } + + // D-pad turns are always interpreted in camera/display space. + let dpadCanFire = currentTime - lastDpadTime > dpadDelay + if dpadCanFire, !gameLogic.state.isGameOver { + var turned = false + if input.dpadUp { + applyDisplayRotation(axis: displayRight, angle: -.pi / 2, updatesDirection: true) + turned = true + } else if input.dpadDown { + applyDisplayRotation(axis: displayRight, angle: .pi / 2, updatesDirection: true) + turned = true + } else if input.dpadLeft { + applyDisplayRotation(axis: displayUp, angle: -.pi / 2, updatesDirection: true) + turned = true + } else if input.dpadRight { + applyDisplayRotation(axis: displayUp, angle: .pi / 2, updatesDirection: true) + turned = true + } + if turned { lastDpadTime = currentTime } + } + + // □ / ○ only roll the snake around the camera forward axis. + let rollCanFire = currentTime - lastRollTime > rollDelay + if rollCanFire, !gameLogic.state.isGameOver { + if input.buttonSquare { + applyDisplayRotation(axis: displayForward, angle: .pi / 2, updatesDirection: false) + lastRollTime = currentTime + } else if input.buttonCircle { + applyDisplayRotation(axis: displayForward, angle: -.pi / 2, updatesDirection: false) + lastRollTime = currentTime + } + } + + // Reset + let optionsPressed = input.buttonCross + // We reuse buttonCross (×) as game-over restart trigger since Options isn't in current input struct + if gameLogic.state.isGameOver && optionsPressed && currentTime - lastOptionsTime > optionsDelay + { + resetToInitialState() + lastOptionsTime = currentTime + } + + // R1 = boost forward speed + gameLogic.isBoosting = input.buttonR1 + } + + // MARK: - Rotation Logic + + private func smoothRotationProgress(_ t: Float) -> Float { + let clamped = max(0, min(1, t)) + return clamped * clamped * (3 - 2 * clamped) + } + + private func applyDisplayRotation(axis: SIMD3, angle: Float, updatesDirection: Bool) { + let displayRotation = simd_quatf(angle: angle, axis: simd_normalize(axis)) + rotationStartQuat = currentWorldQuat + targetWorldQuat = simd_normalize(displayRotation * targetWorldQuat) + rotationProgress = 0.0 + + if updatesDirection { + let worldForward = simd_inverse(targetWorldQuat).act(displayForward) + gameLogic.requestTurn(to: nearestDirection(for: worldForward)) + } + } + + private func nearestDirection(for vector: SIMD3) -> SnakeDirection { + let dirs: [(SnakeDirection, SIMD3)] = [ + (.posX, SIMD3(1, 0, 0)), + (.negX, SIMD3(-1, 0, 0)), + (.posY, SIMD3(0, 1, 0)), + (.negY, SIMD3(0, -1, 0)), + (.posZ, SIMD3(0, 0, 1)), + (.negZ, SIMD3(0, 0, -1)), + ] + let best = dirs.max { a, b in + simd_dot(a.1, vector) < simd_dot(b.1, vector) + } + return best?.0 ?? .posZ + } + + // MARK: - Scene Uniforms + + private func makeSceneUniforms(time: Float, layerCount: UInt32) -> Snake3DSceneUniforms { + let rotMat = simd_matrix4x4(currentWorldQuat) + + // The anchor translation moves all geometry so the snake head ends at headAnchor. + // headWorldPos = gridToWorld(head grid pos) + let headGrid = gameLogic.getInterpolatedHeadPosition(currentTime: TimeInterval(time)) + let headWorld = gridToWorld(headGrid) + // After rotation: rotated head = rotMat * headWorld + // We want rotated head + anchorTranslation = headAnchor + // => anchorTranslation = headAnchor - (rotMat * headWorld).xyz + let rotatedHead = (rotMat * SIMD4(headWorld.x, headWorld.y, headWorld.z, 1.0)).xyz + let anchorTranslation = headAnchor - rotatedHead + + return Snake3DSceneUniforms( + worldRotation: rotMat, + anchorTranslation: anchorTranslation, + time: time, + layerCount: layerCount) + } + + // MARK: - Grid to World Conversion + + private func gridToWorld(_ grid: SIMD3) -> SIMD3 { + return gridToWorld(SIMD3(Float(grid.x), Float(grid.y), Float(grid.z))) + } + + private func gridToWorld(_ grid: SIMD3) -> SIMD3 { + let cell = Snake3DState.cellSize + let g = Float(Snake3DState.gridSize) + return SIMD3( + (grid.x - g * 0.5 + 0.5) * cell, + (grid.y - g * 0.5 + 0.5) * cell, + (grid.z - g * 0.5 + 0.5) * cell + ) + } + + // MARK: - Instance Data Upload + + private func uploadBodyInstances() { + let segs = gameLogic.getSegmentPositions() + let ptr = bodyBuffer.contents().assumingMemoryBound(to: SnakeSegmentInstance.self) + let headColor = SIMD3(0.1, 1.0, 0.2) + for (i, seg) in segs.prefix(maxSegments).enumerated() { + let worldPos = gridToWorld(seg.gridPos) + let t = seg.normalizedIndex + let color = mix(headColor, headColor * 0.2, t) + ptr[i] = SnakeSegmentInstance( + position: worldPos, + color: color, + normalizedIndex: t, + scale: Snake3DState.blockSize) + } + } + + private func uploadFoodInstances() { + let foods = gameLogic.getFoodPositions() + let head = + gameLogic.state.segments.first + ?? SIMD3(Snake3DState.gridSize / 2, Snake3DState.gridSize / 2, Snake3DState.gridSize / 2) + let direction = gameLogic.state.pendingDirection ?? gameLogic.state.direction + let ptr = foodBuffer.contents().assumingMemoryBound(to: FoodInstance.self) + for (i, grid) in foods.prefix(maxFoods).enumerated() { + let isHit = isGuideDashHit(cell: grid, head: head, direction: direction) + ptr[i] = FoodInstance( + position: gridToWorld(grid), + phase: Float(i) * 1.3, + hit: isHit ? 1.0 : 0.0, + colorIndex: Float(i % 4)) + } + } + + private func isGuideDashHit(cell: SIMD3, head: SIMD3, direction: SnakeDirection) -> Bool + { + let period = 4 + // A cell is "on a dash" if any of the 4 spiral rails covers it. + // Rail i covers positions with (pos - headVal) ≡ i*2 (mod period) in forward direction, + // or (pos % period) == i*2 for perpendicular axes. + func anyRailHits(value: Int, headValue: Int, isForward: Bool, negDir: Bool) -> Bool { + for railIdx in 0..<4 { + let offset = railIdx * 2 + if isForward { + if negDir { + let diff = headValue - value + if diff >= 0 && diff % period == offset { return true } + } else { + let diff = value - headValue + if diff >= 0 && diff % period == offset { return true } + } + } else { + if value % period == offset { return true } + } + } + return false + } + + let xForward = direction == .posX || direction == .negX + let yForward = direction == .posY || direction == .negY + let zForward = direction == .posZ || direction == .negZ + + let xOnDash = anyRailHits( + value: cell.x, headValue: head.x, isForward: xForward, negDir: direction == .negX) + let yOnDash = anyRailHits( + value: cell.y, headValue: head.y, isForward: yForward, negDir: direction == .negY) + let zOnDash = anyRailHits( + value: cell.z, headValue: head.z, isForward: zForward, negDir: direction == .negZ) + + let onXLine = cell.y == head.y && cell.z == head.z && xOnDash + let onYLine = cell.x == head.x && cell.z == head.z && yOnDash + let onZLine = cell.x == head.x && cell.y == head.y && zOnDash + return onXLine || onYLine || onZLine + } + + // MARK: - Border Geometry + + private func buildBorderGeometry() { + let g = Float(Snake3DState.gridSize) + let c = Snake3DState.cellSize + // The grid spans [0, g) cells; in world space centred at 0 that's [-g/2, g/2] * cellSize + let half = g * c * 0.5 + + // 8 corners of the bounding box + let corners: [SIMD3] = [ + SIMD3(-half, -half, -half), SIMD3(half, -half, -half), + SIMD3(half, half, -half), SIMD3(-half, half, -half), + SIMD3(-half, -half, half), SIMD3(half, -half, half), + SIMD3(half, half, half), SIMD3(-half, half, half), + ] + + // 12 edges = 24 line-segment endpoints + let edges: [(Int, Int)] = [ + (0, 1), (1, 2), (2, 3), (3, 0), // back face + (4, 5), (5, 6), (6, 7), (7, 4), // front face + (0, 4), (1, 5), (2, 6), (3, 7), // connecting edges + ] + + var instances: [SnakeGuideInstance] = [] + let borderColor = SIMD4(0.28, 0.32, 0.38, 1.0) + let borderThickness: Float = 0.01 + + func addSegment(_ a: SIMD3, _ b: SIMD3, color: SIMD4, thickness: Float) { + let center = (a + b) * 0.5 + let delta = b - a + let scale = SIMD3( + max(abs(delta.x), thickness), + max(abs(delta.y), thickness), + max(abs(delta.z), thickness) + ) + instances.append(SnakeGuideInstance(position: center, scale: scale, color: color)) + } + + for (a, b) in edges { + addSegment(corners[a], corners[b], color: borderColor, thickness: borderThickness) + } + + borderInstanceCount = instances.count + borderBuffer = device.makeBuffer( + bytes: instances, + length: MemoryLayout.stride * instances.count, + options: .storageModeShared)! + } + + private func updateGuideGeometry() { + let g = Float(Snake3DState.gridSize) + let xHintColor = SIMD4(0.46, 0.34, 0.18, 1.0) // darker hint axis (X control cue) + let yHintColor = SIMD4(0.72, 0.96, 0.78, 1.0) // brighter hint axis (Y control cue) + let neutralColor = SIMD4(0.30, 0.36, 0.44, 1.0) + let dashLength = Snake3DState.cellSize * 0.55 + let dashThickness = Snake3DState.blockSize * 0.08 + let head = + gameLogic.state.segments.first + ?? SIMD3(Snake3DState.gridSize / 2, Snake3DState.gridSize / 2, Snake3DState.gridSize / 2) + let direction = gameLogic.state.pendingDirection ?? gameLogic.state.direction + let ptr = guideBuffer.contents().assumingMemoryBound(to: SnakeGuideInstance.self) + var instanceIndex = 0 + + func addDash(_ cell: SIMD3, scale: SIMD3, color: SIMD4) { + let position = SIMD3( + (Float(cell.x) - g * 0.5 + 0.5) * Snake3DState.cellSize, + (Float(cell.y) - g * 0.5 + 0.5) * Snake3DState.cellSize, + (Float(cell.z) - g * 0.5 + 0.5) * Snake3DState.cellSize + ) + ptr[instanceIndex] = SnakeGuideInstance(position: position, scale: scale, color: color) + instanceIndex += 1 + } + + func axisFamily(_ dir: SnakeDirection) -> Int { + switch dir { + case .posX, .negX: return 0 + case .posY, .negY: return 1 + case .posZ, .negZ: return 2 + } + } + + let displayRightInGrid = simd_inverse(currentWorldQuat).act(displayRight) + let displayUpInGrid = simd_inverse(currentWorldQuat).act(displayUp) + let rightFamily = axisFamily(nearestDirection(for: displayRightInGrid)) + let upFamily = axisFamily(nearestDirection(for: displayUpInGrid)) + + func colorForWorldAxisFamily(_ family: Int) -> SIMD4 { + if family == upFamily { + return yHintColor + } + if family == rightFamily { + return xHintColor + } + return neutralColor + } + + let xColor = colorForWorldAxisFamily(0) + let yColor = colorForWorldAxisFamily(1) + let zColor = colorForWorldAxisFamily(2) + let xScale = SIMD3(dashLength, dashThickness, dashThickness) + let yScale = SIMD3(dashThickness, dashLength, dashThickness) + let zScale = SIMD3(dashThickness, dashThickness, dashLength) + + let cell = Snake3DState.cellSize + let hx = Float(head.x) + let hy = Float(head.y) + let hz = Float(head.z) + let size = Snake3DState.gridSize + + func gridWorldF(_ x: Float, _ y: Float, _ z: Float) -> SIMD3 { + SIMD3( + (x - g * 0.5 + 0.5) * cell, + (y - g * 0.5 + 0.5) * cell, + (z - g * 0.5 + 0.5) * cell + ) + } + + // Spiral rails: 4 corners arranged in rotational order around each axis. + // Each rail i has dash phase offset i*2, period 8 → collectively same coverage + // as stride-2 but each rail is 4× less dense, creating a helix appearance. + let spiralCorners: [(Float, Float)] = [(0.5, 0.5), (0.5, -0.5), (-0.5, -0.5), (-0.5, 0.5)] + let period = 4 + + // Dash positions for one rail along an axis. + // fullGrid=true → cover whole axis with phase offset; false → forward from head. + func dashIndices(headVal: Int, railIdx: Int, fullGrid: Bool, negDir: Bool) -> [Int] { + let offset = railIdx * 2 + if fullGrid { + return Array(stride(from: offset, to: size, by: period)) + } else if negDir { + let start = headVal - offset + guard start >= 0 else { return [] } + return Array(stride(from: start, through: 0, by: -period)) + } else { + let start = headVal + offset + guard start < size else { return [] } + return Array(stride(from: start, to: size, by: period)) + } + } + + let xForward = direction == .posX || direction == .negX + let yForward = direction == .posY || direction == .negY + let zForward = direction == .posZ || direction == .negZ + let xNeg = direction == .negX + let yNeg = direction == .negY + let zNeg = direction == .negZ + + for railIdx in 0..<4 { + let (pA, pB) = spiralCorners[railIdx] + + // X-axis rail (corners in YZ plane) + for x in dashIndices(headVal: head.x, railIdx: railIdx, fullGrid: !xForward, negDir: xNeg) { + let pos = gridWorldF(Float(x), hy + pA, hz + pB) + ptr[instanceIndex] = SnakeGuideInstance(position: pos, scale: xScale, color: xColor) + instanceIndex += 1 + } + // Y-axis rail (corners in XZ plane) + for y in dashIndices(headVal: head.y, railIdx: railIdx, fullGrid: !yForward, negDir: yNeg) { + let pos = gridWorldF(hx + pA, Float(y), hz + pB) + ptr[instanceIndex] = SnakeGuideInstance(position: pos, scale: yScale, color: yColor) + instanceIndex += 1 + } + // Z-axis rail (corners in XY plane) + for z in dashIndices(headVal: head.z, railIdx: railIdx, fullGrid: !zForward, negDir: zNeg) { + let pos = gridWorldF(hx + pA, hy + pB, Float(z)) + ptr[instanceIndex] = SnakeGuideInstance(position: pos, scale: zScale, color: zColor) + instanceIndex += 1 + } + } + + guideInstanceCount = instanceIndex + } + + private func drawBorder( + encoder: MTLRenderCommandEncoder, + uniforms: inout Snake3DSceneUniforms, + viewMatrices: [simd_float4x4] + ) { + guard borderInstanceCount > 0 else { return } + encoder.setRenderPipelineState(borderPipelineState) + encoder.setDepthStencilState(depthStencilState) + encoder.setVertexBuffer(cubeMesh.vertexBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(borderBuffer, offset: 0, index: 1) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 2) + viewMatrices.withUnsafeBytes { raw in + if let base = raw.baseAddress, raw.count > 0 { + encoder.setVertexBytes(base, length: raw.count, index: 3) + } + } + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: cubeMesh.indexCount, + indexType: .uint16, + indexBuffer: cubeMesh.indexBuffer, + indexBufferOffset: 0, + instanceCount: borderInstanceCount) + } + + private func drawGuides( + encoder: MTLRenderCommandEncoder, + uniforms: inout Snake3DSceneUniforms, + viewMatrices: [simd_float4x4] + ) { + guard guideInstanceCount > 0 else { return } + encoder.setRenderPipelineState(borderPipelineState) + encoder.setDepthStencilState(depthStencilState) + encoder.setVertexBuffer(cubeMesh.vertexBuffer, offset: 0, index: 0) + encoder.setVertexBuffer(guideBuffer, offset: 0, index: 1) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 2) + viewMatrices.withUnsafeBytes { raw in + if let base = raw.baseAddress, raw.count > 0 { + encoder.setVertexBytes(base, length: raw.count, index: 3) + } + } + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: cubeMesh.indexCount, + indexType: .uint16, + indexBuffer: cubeMesh.indexBuffer, + indexBufferOffset: 0, + instanceCount: guideInstanceCount) + } +} + +// MARK: - SIMD helpers + +private func mix(_ a: SIMD3, _ b: SIMD3, _ t: Float) -> SIMD3 { + return a + (b - a) * t +} + +extension SIMD4 { + fileprivate var xyz: SIMD3 { SIMD3(x, y, z) } +} + +// MARK: - Pipeline & Geometry Creation + +extension Snake3DRenderer { + + fileprivate static func makeBodyPipeline( + device: MTLDevice, library: MTLLibrary, maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "snake3DBodyVertexShader") + desc.fragmentFunction = library.makeFunction(name: "snake3DBodyFragmentShader") + desc.colorAttachments[0].pixelFormat = .rgba16Float + desc.depthAttachmentPixelFormat = .depth32Float + desc.inputPrimitiveTopology = .triangle + desc.maxVertexAmplificationCount = max(maxViewCount, 1) + + 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 + + return try device.makeRenderPipelineState(descriptor: desc) + } + + fileprivate static func makeFoodPipeline( + device: MTLDevice, library: MTLLibrary, maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "snake3DFoodVertexShader") + desc.fragmentFunction = library.makeFunction(name: "snake3DFoodFragmentShader") + desc.colorAttachments[0].pixelFormat = .rgba16Float + desc.depthAttachmentPixelFormat = .depth32Float + desc.inputPrimitiveTopology = .triangle + desc.maxVertexAmplificationCount = max(maxViewCount, 1) + + 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 + + return try device.makeRenderPipelineState(descriptor: desc) + } + + fileprivate static func makeBorderPipeline( + device: MTLDevice, library: MTLLibrary, maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let desc = MTLRenderPipelineDescriptor() + desc.vertexFunction = library.makeFunction(name: "snake3DBorderVertexShader") + desc.fragmentFunction = library.makeFunction(name: "snake3DBorderFragmentShader") + desc.colorAttachments[0].pixelFormat = .rgba16Float + desc.colorAttachments[0].isBlendingEnabled = false + desc.depthAttachmentPixelFormat = .depth32Float + desc.inputPrimitiveTopology = .triangle + desc.maxVertexAmplificationCount = max(maxViewCount, 1) + + 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 + + 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)! + } + + fileprivate static func makeTransparentDepthStencilState(device: MTLDevice) + -> MTLDepthStencilState + { + let desc = MTLDepthStencilDescriptor() + desc.depthCompareFunction = .greater + desc.isDepthWriteEnabled = false + return device.makeDepthStencilState(descriptor: desc)! + } + + fileprivate static func makeCubeGeometry(device: MTLDevice) -> MeshBuffers { + let h: Float = 0.5 + let vertices: [SnakeMeshVertex] = [ + // Front (+Z) + SnakeMeshVertex(position: [-h, -h, h], normal: [0, 0, 1]), + SnakeMeshVertex(position: [h, -h, h], normal: [0, 0, 1]), + SnakeMeshVertex(position: [h, h, h], normal: [0, 0, 1]), + SnakeMeshVertex(position: [-h, h, h], normal: [0, 0, 1]), + // Back (-Z) + SnakeMeshVertex(position: [-h, -h, -h], normal: [0, 0, -1]), + SnakeMeshVertex(position: [h, -h, -h], normal: [0, 0, -1]), + SnakeMeshVertex(position: [h, h, -h], normal: [0, 0, -1]), + SnakeMeshVertex(position: [-h, h, -h], normal: [0, 0, -1]), + // Left (-X) + SnakeMeshVertex(position: [-h, -h, -h], normal: [-1, 0, 0]), + SnakeMeshVertex(position: [-h, -h, h], normal: [-1, 0, 0]), + SnakeMeshVertex(position: [-h, h, h], normal: [-1, 0, 0]), + SnakeMeshVertex(position: [-h, h, -h], normal: [-1, 0, 0]), + // Right (+X) + SnakeMeshVertex(position: [h, -h, -h], normal: [1, 0, 0]), + SnakeMeshVertex(position: [h, -h, h], normal: [1, 0, 0]), + SnakeMeshVertex(position: [h, h, h], normal: [1, 0, 0]), + SnakeMeshVertex(position: [h, h, -h], normal: [1, 0, 0]), + // Top (+Y) + SnakeMeshVertex(position: [-h, h, h], normal: [0, 1, 0]), + SnakeMeshVertex(position: [h, h, h], normal: [0, 1, 0]), + SnakeMeshVertex(position: [h, h, -h], normal: [0, 1, 0]), + SnakeMeshVertex(position: [-h, h, -h], normal: [0, 1, 0]), + // Bottom (-Y) + SnakeMeshVertex(position: [-h, -h, h], normal: [0, -1, 0]), + SnakeMeshVertex(position: [h, -h, h], normal: [0, -1, 0]), + SnakeMeshVertex(position: [h, -h, -h], normal: [0, -1, 0]), + SnakeMeshVertex(position: [-h, -h, -h], normal: [0, -1, 0]), + ] + + let indices: [UInt16] = [ + 0, 1, 2, 0, 2, 3, // Front + 4, 5, 6, 4, 6, 7, // Back + 8, 9, 10, 8, 10, 11, // Left + 12, 13, 14, 12, 14, 15, // Right + 16, 17, 18, 16, 18, 19, // Top + 20, 21, 22, 20, 22, 23, // Bottom + ] + + let vb = device.makeBuffer( + bytes: vertices, + length: MemoryLayout.stride * vertices.count, + options: .storageModeShared)! + let ib = device.makeBuffer( + bytes: indices, + length: MemoryLayout.stride * indices.count, + options: .storageModeShared)! + + return (vb, ib, indices.count) + } +} diff --git a/vr-dive/Demos/Snake3D/Snake3DShaders.metal b/vr-dive/Demos/Snake3D/Snake3DShaders.metal new file mode 100644 index 0000000..9ac9f09 --- /dev/null +++ b/vr-dive/Demos/Snake3D/Snake3DShaders.metal @@ -0,0 +1,276 @@ +#include +using namespace metal; + +// MARK: - Shared Types + +struct Snake3DSceneUniforms { + float4x4 worldRotation; + float3 anchorTranslation; + float time; + uint layerCount; + float3 padding; +}; + +struct SnakeMeshVertex { + float3 position; + float3 normal; +}; + +struct SnakeSegmentInstance { + float3 position; // grid-space world position (before rotation) + float4 colorAndIndex; // rgb=color, a=normalizedIndex + float scale; + float3 padding; +}; + +struct FoodInstance { + float3 position; + float phase; + float hit; + float colorIndex; + float2 padding; +}; + +struct SnakeGuideInstance { + float3 position; + float3 scale; + float4 color; +}; + +// MARK: - Snake Body Shader + +struct SnakeBodyVertexOut { + float4 position [[position]]; + float3 normal; + float3 worldPos; + float3 localPos; + float4 color; +}; + +// Apply world rotation transform: rotate around head, then offset to anchor +float3 applyWorldTransform(float3 pos, + float4x4 rotation, + float3 anchor) { + float4 rotated = rotation * float4(pos, 1.0); + return rotated.xyz + anchor; +} + +vertex SnakeBodyVertexOut snake3DBodyVertexShader( + ushort amplificationID [[amplification_id]], + const device SnakeMeshVertex *vertices [[buffer(0)]], + const device SnakeSegmentInstance *instances [[buffer(1)]], + constant Snake3DSceneUniforms &uniforms [[buffer(2)]], + constant float4x4 *viewProjectionMatrices [[buffer(3)]], + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]]) +{ + SnakeBodyVertexOut out; + uint layers = max(uniforms.layerCount, 1u); + uint viewIndex = min((uint)amplificationID, layers - 1u); + + SnakeMeshVertex vtx = vertices[vertexID]; + SnakeSegmentInstance inst = instances[instanceID]; + + // Local cube vertex scaled by block size, centred on instance position + float3 localPos = vtx.position * inst.scale; + float3 worldPos = inst.position + localPos; + + // Apply world rotation (rotates the whole scene so head stays at front) + float3 transformed = applyWorldTransform(worldPos, + uniforms.worldRotation, + uniforms.anchorTranslation); + + float4x4 vp = viewProjectionMatrices[viewIndex]; + out.position = vp * float4(transformed, 1.0); + + // Rotate normal too (only rotation, not translation) + float3 rotatedNormal = (uniforms.worldRotation * float4(vtx.normal, 0.0)).xyz; + out.normal = rotatedNormal; + out.worldPos = transformed; + out.localPos = vtx.position; + out.color = inst.colorAndIndex; + return out; +} + +fragment float4 snake3DBodyFragmentShader(SnakeBodyVertexOut in [[stage_in]], + constant Snake3DSceneUniforms &uniforms [[buffer(0)]], + bool isFrontFacing [[front_facing]]) +{ + float3 normal = normalize(in.normal); + normal = isFrontFacing ? normal : -normal; + + float3 lightDir = normalize(float3(0.3, 1.0, -0.5)); + float ndotl = max(dot(normal, lightDir), 0.0); + + float3 viewDir = normalize(-in.worldPos); + float3 halfVec = normalize(lightDir + viewDir); + float spec = pow(max(dot(normal, halfVec), 0.0), 32.0) * 0.3; + + // Edge darkening on cube faces + float3 absLocal = abs(in.localPos); + float edgeDist = max(max(absLocal.x, absLocal.y), absLocal.z); + float edgeFactor = smoothstep(0.35, 0.48, edgeDist); + + float3 baseColor = in.color.rgb; + // Tail attenuation: dim toward tail + float t = in.color.a; // 0=head, 1=tail + baseColor = mix(baseColor, baseColor * 0.25, t * 0.7); + + // Diagonal gradient to break up visual merging between adjacent segments. + float3 bodyGradDir = normalize(float3(0.6, 0.8, 0.4)); + float bodyGradT = saturate(dot(in.localPos, bodyGradDir) * 2.0 + 0.5); + baseColor = mix(baseColor * 0.88, baseColor * 1.12, bodyGradT); + + float3 edgeColor = baseColor * 0.25; + baseColor = mix(baseColor, edgeColor, edgeFactor); + + float3 color = baseColor * (0.5 + ndotl * 0.6) + spec; + return float4(color, 1.0); +} + +// MARK: - Food Shader + +struct FoodVertexOut { + float4 position [[position]]; + float3 normal; + float3 worldPos; + float3 localPos; + float time; + float phase; + float hit; + float colorIndex; +}; + +vertex FoodVertexOut snake3DFoodVertexShader( + ushort amplificationID [[amplification_id]], + const device SnakeMeshVertex *vertices [[buffer(0)]], + const device FoodInstance *instances [[buffer(1)]], + constant Snake3DSceneUniforms &uniforms [[buffer(2)]], + constant float4x4 *viewProjectionMatrices [[buffer(3)]], + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]]) +{ + FoodVertexOut out; + uint layers = max(uniforms.layerCount, 1u); + uint viewIndex = min((uint)amplificationID, layers - 1u); + + SnakeMeshVertex vtx = vertices[vertexID]; + FoodInstance inst = instances[instanceID]; + + // Shrink block only when very close to camera to avoid obstructing view. + // Block center distance determines scale; normal size beyond ~0.6 m. + float3 centerTransformed = applyWorldTransform(inst.position, + uniforms.worldRotation, + uniforms.anchorTranslation); + float camDist = length(centerTransformed); + float proximity = saturate(1.0 - camDist / 0.6); // 1=touching cam, 0=>=0.6m + float normalSize = 0.192; + float minSize = 0.04; + float blockSize = mix(normalSize, minSize, proximity * proximity); + float3 worldPos = inst.position + vtx.position * blockSize; + + float3 transformed = applyWorldTransform(worldPos, + uniforms.worldRotation, + uniforms.anchorTranslation); + + float4x4 vp = viewProjectionMatrices[viewIndex]; + out.position = vp * float4(transformed, 1.0); + out.normal = (uniforms.worldRotation * float4(vtx.normal, 0.0)).xyz; + out.worldPos = transformed; + out.localPos = vtx.position; + out.time = uniforms.time; + out.phase = inst.phase; + out.hit = inst.hit; + out.colorIndex = inst.colorIndex; + return out; +} + +fragment float4 snake3DFoodFragmentShader(FoodVertexOut in [[stage_in]], + constant Snake3DSceneUniforms &uniforms [[buffer(0)]], + bool isFrontFacing [[front_facing]]) +{ + float3 normal = normalize(in.normal); + normal = isFrontFacing ? normal : -normal; + + float3 lightDir = normalize(float3(0.3, 1.0, -0.5)); + float ndotl = max(dot(normal, lightDir), 0.0); + + // Per-food base color from palette (orange / blue / purple / hot-pink) + float3 paletteColor; + int ci = clamp(int(in.colorIndex), 0, 3); + if (ci == 0) paletteColor = float3(1.00, 0.55, 0.05); // orange + else if (ci == 1) paletteColor = float3(0.20, 0.50, 1.00); // blue + else if (ci == 2) paletteColor = float3(0.75, 0.15, 1.00); // purple + else paletteColor = float3(1.00, 0.20, 0.55); // hot pink + + // Fixed-direction diagonal gradient so adjacent same-color blocks stay distinguishable. + // Project local position onto a fixed diagonal; this produces a unique shade per face corner. + float3 gradDir = normalize(float3(0.6, 0.8, 0.4)); + float gradT = dot(in.localPos, gradDir) * 2.0 + 0.5; // roughly [-0.5, 1.5] + gradT = saturate(gradT); + float3 gradColor = mix(paletteColor * 0.82, paletteColor * 1.18, gradT); + + float3 normalColor = gradColor; + float3 hitColor = float3(0.15, 0.95, 1.0); + float3 baseColor = mix(normalColor, hitColor, saturate(in.hit)); + + float3 absLocal = abs(in.localPos); + float edgeDist = max(max(absLocal.x, absLocal.y), absLocal.z); + float edgeFactor = smoothstep(0.35, 0.48, edgeDist); + baseColor = mix(baseColor, baseColor * 0.3, edgeFactor); + + // Distance-based brightness: closer = brighter, farther = darker. + float dist = length(in.worldPos); + float distFade = saturate(1.0 - dist * 0.13); // full at ~0m, half at ~5m + float distScale = mix(0.55, 1.25, distFade); + + float3 color = baseColor * (0.55 + ndotl * 0.55) * distScale; + return float4(color, 1.0); +} + +// MARK: - Border (wireframe box) + +struct BorderVertexOut { + float4 position [[position]]; + float3 normal; + float4 color; +}; + +vertex BorderVertexOut snake3DBorderVertexShader( + ushort amplificationID [[amplification_id]], + const device SnakeMeshVertex *vertices [[buffer(0)]], + const device SnakeGuideInstance *instances [[buffer(1)]], + constant Snake3DSceneUniforms &uniforms [[buffer(2)]], + constant float4x4 *viewProjectionMatrices [[buffer(3)]], + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]]) +{ + BorderVertexOut out; + uint layers = max(uniforms.layerCount, 1u); + uint viewIndex = min((uint)amplificationID, layers - 1u); + + SnakeMeshVertex vtx = vertices[vertexID]; + SnakeGuideInstance inst = instances[instanceID]; + + float3 worldPos = inst.position + vtx.position * inst.scale; + float3 transformed = applyWorldTransform(worldPos, + uniforms.worldRotation, + uniforms.anchorTranslation); + + float4x4 vp = viewProjectionMatrices[viewIndex]; + out.position = vp * float4(transformed, 1.0); + out.normal = (uniforms.worldRotation * float4(vtx.normal, 0.0)).xyz; + out.color = inst.color; + return out; +} + +fragment float4 snake3DBorderFragmentShader(BorderVertexOut in [[stage_in]], + bool isFrontFacing [[front_facing]]) +{ + float3 normal = normalize(in.normal); + normal = isFrontFacing ? normal : -normal; + float3 lightDir = normalize(float3(0.25, 1.0, -0.4)); + float ndotl = max(dot(normal, lightDir), 0.0); + float3 color = in.color.rgb * (0.65 + ndotl * 0.45); + return float4(color, 1.0); +} diff --git a/vr-dive/Demos/Snake3D/Snake3DTypes.swift b/vr-dive/Demos/Snake3D/Snake3DTypes.swift new file mode 100644 index 0000000..3a8123e --- /dev/null +++ b/vr-dive/Demos/Snake3D/Snake3DTypes.swift @@ -0,0 +1,114 @@ +import simd + +// MARK: - Direction (6 faces of a cube) + +enum SnakeDirection: Int, CaseIterable { + case posZ = 0 + case negZ = 1 + case posX = 2 + case negX = 3 + case posY = 4 + case negY = 5 + + var delta: SIMD3 { + switch self { + case .posZ: return SIMD3(0, 0, 1) + case .negZ: return SIMD3(0, 0, -1) + case .posX: return SIMD3(1, 0, 0) + case .negX: return SIMD3(-1, 0, 0) + case .posY: return SIMD3(0, 1, 0) + case .negY: return SIMD3(0, -1, 0) + } + } + + var opposite: SnakeDirection { + switch self { + case .posZ: return .negZ + case .negZ: return .posZ + case .posX: return .negX + case .negX: return .posX + case .posY: return .negY + case .negY: return .posY + } + } +} + +// MARK: - Game State + +struct Snake3DState { + static let gridSize: Int = 80 + static let cellSize: Float = 0.2 // enlarged grid spacing for a larger play volume + static let blockSize: Float = 0.192 // keep guide cells and snake blocks visually consistent + + var segments: [SIMD3] // index 0 = head + var direction: SnakeDirection + var pendingDirection: SnakeDirection? // queued turn applied on next step + var foods: [SIMD3] + var score: Int + var isGameOver: Bool + var pendingGrow: Int // how many segments to add + + init() { + let mid = Snake3DState.gridSize / 2 + segments = [ + SIMD3(mid, mid, mid), + SIMD3(mid, mid, mid + 1), + SIMD3(mid, mid, mid + 2), + SIMD3(mid, mid, mid + 3), + SIMD3(mid, mid, mid + 4), + ] + direction = .negZ + pendingDirection = nil + foods = [] + score = 0 + isGameOver = false + pendingGrow = 0 + } +} + +// MARK: - GPU Types + +/// One snake segment instance on GPU +struct SnakeSegmentInstance { + var position: SIMD3 // world position (pre-rotated on CPU) + var colorAndIndex: SIMD4 // rgb = color, a = normalized segment index (0=head) + var scale: Float + var padding: SIMD3 = .zero + + init(position: SIMD3, color: SIMD3, normalizedIndex: Float, scale: Float) { + self.position = position + self.colorAndIndex = SIMD4(color.x, color.y, color.z, normalizedIndex) + self.scale = scale + } +} + +/// One food instance on GPU +struct FoodInstance { + var position: SIMD3 + var phase: Float // animation phase offset + var hit: Float + var colorIndex: Float // 0–3 food color palette index + var padding: SIMD2 = .zero +} + +/// Mesh vertex shared by all snake geometry +struct SnakeMeshVertex { + var position: SIMD3 + var normal: SIMD3 +} + +/// Scene uniforms passed to shaders each frame +struct Snake3DSceneUniforms { + var worldRotation: simd_float4x4 // cumulative world rotation (applied to all geometry) + var anchorTranslation: SIMD3 // offset so snake head is at fixed view point + var time: Float + var layerCount: UInt32 + var padding: SIMD3 = .zero +} + +/// Border line vertex +struct SnakeGuideInstance { + var position: SIMD3 + var scale: SIMD3 + var color: SIMD4 +} diff --git a/vr-dive/Demos/SonicAndTails/SonicAndTailsRenderer.swift b/vr-dive/Demos/SonicAndTails/SonicAndTailsRenderer.swift new file mode 100644 index 0000000..77b7fc7 --- /dev/null +++ b/vr-dive/Demos/SonicAndTails/SonicAndTailsRenderer.swift @@ -0,0 +1,176 @@ +import Metal +import simd + +// SonicAndTailsRenderer.swift +// 3D cube-container adaptation of "Sonic & Tails" (ShaderToy s3X3WX). +// Original: https://www.shadertoy.com/view/s3X3WX by Noztol + +final class SonicAndTailsRenderer: VisualPatternController { + let identifier: VisualPatternKind = .sonicAndTails + 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 = SonicAndTailsRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0)) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SonicAndTailsRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SonicAndTailsRenderer.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 = SonicAndTailsUniforms( + 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 SonicAndTailsRenderer { + 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: "sonicAndTailsVertex") + desc.fragmentFunction = library.makeFunction(name: "sonicAndTailsFragment") + 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/SonicAndTails/SonicAndTailsShaders.metal b/vr-dive/Demos/SonicAndTails/SonicAndTailsShaders.metal new file mode 100644 index 0000000..595b78d --- /dev/null +++ b/vr-dive/Demos/SonicAndTails/SonicAndTailsShaders.metal @@ -0,0 +1,320 @@ +// SonicAndTailsShaders.metal +// 3D visionOS adaptation of "Sonic & Tails" (ShaderToy s3X3WX). +// +// Original GLSL: +// https://www.shadertoy.com/view/s3X3WX +// "Sonic & Tails" by Noztol +// Ported to Metal / visionOS cube-container by the vr-dive project. +// +// Technique: SDF ray marching inside a procedural spiral pipe with golden rings, +// volumetric neon-rail glow, ring halos, and a cloud-backed sky for missed rays. +// +// GLSL → Metal translation notes: +// • mat3 is column-major in both; constructor args map 1-to-1 into float3x3 columns. +// • GLSL `v.xz *= mat2(c,-s,s,c)` (row-vec) = Metal `v.xz = float2x2(float2(c,s), float2(-s,c)) * v.xz` +// • GLSL `atan(y, x)` two-arg form = Metal `atan2(y, x)`. +// • `iTime` inside map() is passed explicitly as parameter `t`. + +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Shared types (must match SonicAndTailsTypes.swift) +// --------------------------------------------------------------------------- + +struct SonicAndTailsUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SonicAndTailsVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// FBM rotation matrix +// Original: mat3(0.00, 1.60, 1.20, -1.60, 0.72, -0.96, -1.20, -0.96, 1.28) +// GLSL column-major col0=(0.00,1.60,1.20), col1=(-1.60,0.72,-0.96), col2=(-1.20,-0.96,1.28) +// --------------------------------------------------------------------------- + +constant float3x3 satM3 = float3x3( + float3( 0.00f, 1.60f, 1.20f), + float3(-1.60f, 0.72f, -0.96f), + float3(-1.20f, -0.96f, 1.28f) +); + +// --------------------------------------------------------------------------- +// Procedural noise +// --------------------------------------------------------------------------- + +static float satHash(float n) { + return fract(sin(n) * 43758.5453123f); +} + +static float satNoise(float3 x) { + float3 p = floor(x); + float3 f = fract(x); + f = f * f * (3.0f - 2.0f * f); + float n = p.x + p.y * 57.0f + 113.0f * p.z; + return mix( + mix(mix(satHash(n + 0.0f), satHash(n + 1.0f), f.x), + mix(satHash(n + 57.0f), satHash(n + 58.0f), f.x), f.y), + mix(mix(satHash(n + 113.0f), satHash(n + 114.0f), f.x), + mix(satHash(n + 170.0f), satHash(n + 171.0f), f.x), f.y), f.z); +} + +static float satFbm(float3 p) { + float f = 0.5000f * satNoise(p); p = satM3 * p * 1.1f; + f += 0.2500f * satNoise(p); p = satM3 * p * 1.2f; + f += 0.1666f * satNoise(p); p = satM3 * p; + f += 0.0834f * satNoise(p); + return f; +} + +// --------------------------------------------------------------------------- +// Geometry helpers +// --------------------------------------------------------------------------- + +// Spiral tunnel centre at path parameter z +static float3 satP(float z) { + return float3(cos(z * 0.02f) * 12.0f + cos(z * 0.05f) * 6.0f, + sin(z * 0.015f) * 8.0f, + z); +} + +static float satSdTorus(float3 p, float2 t) { + float2 q = float2(length(p.xy) - t.x, p.z); + return length(q) - t.y; +} + +// SDF map — returns float2(dist, materialID) +// material 1.0 = track wall, 2.0 = golden ring +static float2 satMap(float3 p, float t) { + float3 path = satP(p.z); + float3 loc = p - path; + + // Pipe interior (PIPE_RAD = 10) capped by track floor + float pipe = 10.0f - length(loc.xy); + float dTrack = max(pipe, loc.y - 3.8f); + float2 res = float2(dTrack, 1.0f); + + // Golden rings every 20 units along z + float zIndex = floor(p.z / 20.0f) * 20.0f; + for (float i = -1.0f; i <= 1.0f; i++) { + float currZ = zIndex + i * 20.0f; + float3 rPos = satP(currZ); + rPos.x += sin(currZ * 0.15f) * 6.0f; // RING_FREQ=0.15, RING_AMP=6 + rPos.y -= 4.5f; + float3 rLocal = p - rPos; + + // GLSL: rLocal.xz *= mat2(c,-s,s,c) (row-vec × col-major mat2) + // Metal col-vec equiv: rLocal.xz = float2x2(col0=(c,s), col1=(-s,c)) * rLocal.xz + float rot = t * 4.0f; + float c = cos(rot), s = sin(rot); + rLocal.xz = float2x2(float2(c, s), float2(-s, c)) * rLocal.xz; + + float dRing = satSdTorus(rLocal, float2(1.8f, 0.3f)); + if (dRing < res.x) res = float2(dRing, 2.0f); + } + return res; +} + +static float3 satGetNormal(float3 p, float t) { + const float2 e = float2(0.01f, 0.0f); + return normalize(float3( + satMap(p + e.xyy, t).x - satMap(p - e.xyy, t).x, + satMap(p + e.yxy, t).x - satMap(p - e.yxy, t).x, + satMap(p + e.yyx, t).x - satMap(p - e.yyx, t).x)); +} + +// Axis-aligned box intersection; returns (tNear, tFar) +static float2 satBoxIntersect(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)); +} + +// --------------------------------------------------------------------------- +// Vertex +// --------------------------------------------------------------------------- + +vertex SonicAndTailsVertexOut sonicAndTailsVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SonicAndTailsUniforms &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; + + SonicAndTailsVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment — SDF ray marching, track/ring/sky shading +// --------------------------------------------------------------------------- + +fragment float4 sonicAndTailsFragment( + SonicAndTailsVertexOut in [[stage_in]], + constant SonicAndTailsUniforms &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); // cube local ±1 + + // Camera and surface position 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 + bool insideBox = all(abs(eye) < halfExt - 1.0e-3f); + float2 tBox = satBoxIntersect(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(); } + + // Map cube-local entry point into scene space. + // sceneScale=10 maps cube ±1 → ±10 scene units (≈ one tunnel diameter). + // Virtual camera follows the tunnel path with the original lateral swing, + // offset to the track floor level — mirrors the original ro setup. + // sceneScale=20: tunnel radius 10 occupies ±0.5 local units, leaving the cube + // centre open so rays through the open tunnel exit and show the far face. + const float sceneScale = 20.0f; + const float T = uniforms.time * 14.0f; // path parameter + float camSwing = sin(T * 0.15f) * 6.0f; // RING_FREQ * RING_AMP + float3 camPos = satP(T) + float3(camSwing, -4.5f, 0.0f); + + // Flip z: ShaderToy content runs in +Z; cube local -Z must map to scene +Z. + float3 ro_entry = (eye + viewDir * (tStart + 0.001f)); + float3 ro = float3(ro_entry.x, ro_entry.y, -ro_entry.z) * sceneScale + camPos; + float3 rd = float3(viewDir.x, viewDir.y, -viewDir.z); + + // Maximum scene-space march distance bounded by the cube exit point + float maxDist = (tEnd - tStart) * sceneScale; + + float d = 0.0f; + float glowLine = 0.0f; + float glowRing = 0.0f; + float2 res = float2(1.0f, 1.0f); + + // ----------------------------------------------------------------------- + // Ray march — 90 steps (matching original) + // ----------------------------------------------------------------------- + for (int i = 0; i < 90; i++) { + float3 p = ro + rd * d; + res = satMap(p, uniforms.time); + + // 1. Unified track glow — neon rail lines + horizontal ring bands + float3 path = satP(p.z); + float3 loc = p - path; + if (length(loc.xy) < 12.0f && loc.y < 4.0f) { + // GLSL atan(x, y) two-arg = Metal atan2(x, y) + float angle = atan2(loc.x, loc.y); + float rails = smoothstep(0.12f, 0.0f, abs(abs(angle) - 0.5f)); + float rings = smoothstep(0.45f, 0.5f, abs(fract(p.z * 0.15f) - 0.5f)); + float lineMask = max(rails, rings); + glowLine += lineMask * 0.06f / (res.x + 0.1f); + } + + // 2. Volumetric golden-ring halo + float zIdx = floor(p.z / 20.0f) * 20.0f; + float3 rPos = satP(zIdx); + rPos.x += sin(zIdx * 0.15f) * 6.0f; + rPos.y -= 4.5f; + glowRing += 0.02f / (length(p - rPos) + 0.5f); + + if (res.x < 0.001f || d > maxDist) break; + d += res.x * 0.75f; + } + + // ----------------------------------------------------------------------- + // Surface / sky shading + // ----------------------------------------------------------------------- + float3 col; + float3 sunDir = normalize(float3(0.1f, 0.25f, 0.9f)); + + if (d >= maxDist) { + // Ray exited cube without hitting geometry → sky & clouds + float sundot = clamp(dot(rd, sunDir), 0.0f, 1.0f); + float tt = pow(1.0f - 0.7f * rd.y, 15.0f); + col = 0.8f * (float3(0.6f, 0.8f, 1.2f) * tt + + float3(0.05f, 0.2f, 0.5f) * (1.0f - tt)); + col += 0.5f * float3(1.6f, 1.4f, 1.0f) * pow(sundot, 300.0f); + + float4 cloudSum = float4(0.0f); + for (int q = 0; q < 20; q++) { + float h = (float(q) * 15.0f + 320.0f - ro.y) / rd.y; + if (h > 0.0f && h < 8000.0f) { + float3 cp = ro + h * rd + + float3(0.0f, -uniforms.time * 8.0f, uniforms.time * 15.0f); + float den = smoothstep(0.5f, 1.0f, satFbm(cp * 0.0018f)); + float3 cCol = mix(float3(1.1f), float3(0.4f, 0.4f, 0.45f), den); + den *= (1.0f - cloudSum.w); + cloudSum += float4(cCol * den, den); + if (cloudSum.w > 0.98f) break; + } + } + col = mix(col, cloudSum.rgb, cloudSum.w * (1.0f - tt)); + } else { + float3 p = ro + rd * d; + float3 n = satGetNormal(p, uniforms.time); + + if (res.y > 1.5f) { + // Material: Golden Ring + float spec = pow(max(dot(reflect(rd, n), sunDir), 0.0f), 32.0f); + col = float3(1.0f, 0.8f, 0.1f) + spec; + } else { + // Material: Neon Track + float3 path = satP(p.z); + float3 loc = p - path; + float angle = atan2(loc.x, loc.y); + + float rings = smoothstep(0.35f, 0.5f, abs(fract(p.z * 0.15f) - 0.5f)); + float rails = smoothstep(0.40f, 0.5f, abs(fract(angle * 0.418f) - 0.5f)); + float mask = max(rails, rings); + + float3 neonBlue = float3(0.0f, 0.8f, 5.0f) * (1.0f - mask); + float3 neonRed = float3(4.5f, 0.2f, 0.0f) * (4.0f * mask); + + col = mix(neonBlue, neonRed, mask); + col = mix(col, neonRed * 1.5f, smoothstep(3.5f, 3.8f, loc.y)); + col += pow(1.0f - max(dot(n, -rd), 0.0f), 4.0f) * float3(0.5f, 0.8f, 1.0f); + } + } + + // Combine volumetric glows + col += float3(1.8f, 0.1f, 0.0f) * glowLine; // neon rail bloom + col += float3(1.0f, 0.6f, 0.0f) * glowRing; // golden-ring bloom + + // tanh tone-map (matches original) + return float4(tanh(col), 1.0f); +} diff --git a/vr-dive/Demos/SonicAndTails/SonicAndTailsTypes.swift b/vr-dive/Demos/SonicAndTails/SonicAndTailsTypes.swift new file mode 100644 index 0000000..a4954af --- /dev/null +++ b/vr-dive/Demos/SonicAndTails/SonicAndTailsTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct SonicAndTailsUniforms in SonicAndTailsShaders.metal. +struct SonicAndTailsUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Soulstone/SoulstoneRenderer.swift b/vr-dive/Demos/Soulstone/SoulstoneRenderer.swift new file mode 100644 index 0000000..66b8329 --- /dev/null +++ b/vr-dive/Demos/Soulstone/SoulstoneRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// SoulstoneRenderer.swift +// +// Cube-container adaptation of ShaderToy "Soulstone" (llSBRD). +// 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 SoulstoneRenderer: VisualPatternController { + let identifier: VisualPatternKind = .soulstone + 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 = SoulstoneRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SoulstoneRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SoulstoneRenderer.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 = SoulstoneUniforms( + 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 SoulstoneRenderer { + 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: "soulstoneVertex") + desc.fragmentFunction = library.makeFunction(name: "soulstoneFragment") + 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/Soulstone/SoulstoneShaders.metal b/vr-dive/Demos/Soulstone/SoulstoneShaders.metal new file mode 100644 index 0000000..0900c8e --- /dev/null +++ b/vr-dive/Demos/Soulstone/SoulstoneShaders.metal @@ -0,0 +1,404 @@ +// SoulstoneShaders.metal +// Adapted from ShaderToy "Soulstone". +// Source: https://www.shadertoy.com/view/llSBRD +// +// Metal adaptation notes: +// - The original GLSL shader uses cubemap and 2D texture lookups for reflection, +// triplanar detail and UI-like glow modulation. This Metal version replaces +// them with a procedural sky, procedural triplanar noise and analytic glow. +// - The visible 2 m cube is only the entry container. Rays are reconstructed +// from the real per-eye camera pose, begin at the cube surface when viewed +// from outside, and continue marching the crystal beyond the container bounds. +// - GLSL helpers relying on swizzle l-values and `uintBitsToFloat` are recast +// into Metal-safe helper functions. + +#include +using namespace metal; + +struct SoulstoneUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SoulstoneVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct SoulstoneIntersection { + float totalDistance; + float mediumDistance; + float sdf; + float density; + int materialID; + bool hit; +}; + +static constant float SOULSTONE_TAU = 6.2831853f; +static constant float SOULSTONE_CRYSTAL_SCALE = 1.0f; +static constant float SOULSTONE_VERTICAL_ANISOTROPY = 1.3f; +static constant int SOULSTONE_MAX_STEPS = 50; +static constant float SOULSTONE_FIXED_STEP_SIZE = 0.05f; +static constant float SOULSTONE_MAX_DISTANCE = 15.0f; +static constant float SOULSTONE_EPSILON = 0.01f; +static constant float SOULSTONE_EPSILON_NORMAL = 0.1f; +static constant float3 SOULSTONE_BOX_HALF = float3(1.0f); +static constant int SOULSTONE_MATERIAL_NONE = -1; +static constant int SOULSTONE_MATERIAL_CRYSTAL = 1; + +vertex SoulstoneVertexOut soulstoneVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SoulstoneUniforms &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; + + SoulstoneVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float ssSaturate(float x) { + return clamp(x, 0.0f, 1.0f); +} + +static float2 ssRotate2D(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 ssHash11(float n) { + return fract(sin(n * 127.1f) * 43758.5453123f); +} + +static float ssHash31(float3 p) { + return fract(sin(dot(p, float3(0.09123898f, 0.0231233f, 0.0532234f))) * 100000.0f); +} + +static float3 ssHash33(float3 p) { + return fract(sin(float3( + dot(p, float3(127.1f, 311.7f, 74.7f)), + dot(p, float3(269.5f, 183.3f, 246.1f)), + dot(p, float3(113.5f, 271.9f, 124.6f)))) * 43758.5453123f); +} + +static float2 ssComplexSquare(float2 a) { + return float2(a.x * a.x - a.y * a.y, 2.0f * a.x * a.y); +} + +static float3 ssRotateX(float3 p, float t) { + p.yz = ssRotate2D(p.yz, t); + return p; +} + +static float3 ssRotateY(float3 p, float t) { + p.xz = ssRotate2D(p.xz, t); + return p; +} + +static float3 ssPalette(float t, float3 a, float3 b, float3 c, float3 d) { + return clamp(a + b * cos(6.28318f * (c * t + d)), 0.0f, 1.0f); +} + +static float ssNoise2(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + float2 u = f * f * (3.0f - 2.0f * f); + float a = ssHash31(float3(i, 0.13f)); + float b = ssHash31(float3(i + float2(1.0f, 0.0f), 0.13f)); + float c = ssHash31(float3(i + float2(0.0f, 1.0f), 0.13f)); + float d = ssHash31(float3(i + float2(1.0f, 1.0f), 0.13f)); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +static float3 ssProceduralTexture(float2 uv) { + float value = 0.0f; + float amp = 0.55f; + float freq = 1.0f; + for (int i = 0; i < 4; ++i) { + value += ssNoise2(uv * freq) * amp; + freq *= 2.07f; + amp *= 0.5f; + } + float cracks = abs(sin(uv.x * 6.0f) + cos(uv.y * 7.0f)); + float tone = clamp(value * 0.9f + cracks * 0.15f, 0.0f, 1.0f); + return float3(tone, tone * tone, sqrt(tone)); +} + +static float3 ssTriplanar(float3 p, float3 n) { + float3 an = abs(n); + float sum = max(an.x + an.y + an.z, 1.0e-4f); + an /= sum; + float3 c0 = ssProceduralTexture(p.xy).rgb * an.z; + float3 c1 = ssProceduralTexture(p.yz).rgb * an.x; + float3 c2 = ssProceduralTexture(p.xz).rgb * an.y; + return c0 + c1 + c2; +} + +static float2 ssBoxIntersect(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 ssFaceUV(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 ssEnvironment(float3 dir) { + 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.35f, 0.4f, -0.85f))), 0.0f), 44.0f); + float3 sky = mix(float3(0.05f, 0.01f, 0.04f), float3(0.18f, 0.03f, 0.12f), skyMix); + return sky + float3(1.0f, 0.42f, 0.18f) * horizon * 0.35f + float3(1.0f, 0.75f, 0.45f) * sun; +} + +static float ssSeed(float time) { + return floor(time * 0.5f); +} + +static float ssCrystalSDFSimple(float3 p, float time) { + float d = 0.0f; + float seed = ssSeed(time); + float sides = 6.0f; + float sideAmpl = SOULSTONE_TAU / sides; + + for (int i = 0; i < 6; ++i) { + float fi = float(i); + float angle = mix(fi, fi + 1.0f, ssHash11(fi + seed * 3.17f)) * sideAmpl; + float3 offset = float3(cos(angle), 0.0f, sin(angle)); + float3 axis = normalize(offset); + offset = offset * SOULSTONE_CRYSTAL_SCALE / SOULSTONE_VERTICAL_ANISOTROPY; + d = max(d, dot(p - offset, axis)); + } + + float3 capOffset = float3(0.0f, 2.0f, 0.0f); + for (int i = 0; i < 6; ++i) { + float fi = float(i); + float angle = mix(fi, fi + 1.0f, ssHash11(fi + 17.0f + seed * 2.13f)) * sideAmpl; + float randomLift = ssHash11(fi + 31.0f + seed * 5.71f); + float3 axis = normalize(float3(cos(angle), 0.5f + randomLift, sin(angle))); + d = max(d, dot(p - capOffset * SOULSTONE_CRYSTAL_SCALE * SOULSTONE_VERTICAL_ANISOTROPY, axis)); + d = max(d, dot(p + capOffset * SOULSTONE_CRYSTAL_SCALE * SOULSTONE_VERTICAL_ANISOTROPY, -axis)); + } + + return d; +} + +static float ssCurvatureModifier(float3 p, float w, float time) { + float2 e = float2(-1.0f, 1.0f) * w; + float t1 = ssCrystalSDFSimple(p + e.yxx, time); + float t2 = ssCrystalSDFSimple(p + e.xxy, time); + float t3 = ssCrystalSDFSimple(p + e.xyx, time); + float t4 = ssCrystalSDFSimple(p + e.yyy, time); + return (0.25f / e.y) * (t1 + t2 + t3 + t4 - 4.0f * ssCrystalSDFSimple(p, time)); +} + +static float ssCrystalSDF(float3 p, float time) { + return ssCrystalSDFSimple(p, time); +} + +static float3 ssCrystalNormal(float3 p, float epsilon, float time) { + float3 eps = float3(epsilon, -epsilon, 0.0f); + float dX = ssCrystalSDF(p + eps.xzz, time) - ssCrystalSDF(p + eps.yzz, time); + float dY = ssCrystalSDF(p + eps.zxz, time) - ssCrystalSDF(p + eps.zyz, time); + float dZ = ssCrystalSDF(p + eps.zzx, time) - ssCrystalSDF(p + eps.zzy, time); + return normalize(float3(dX, dY, dZ)); +} + +static float ssDensity(float3 p, float time) { + float3 p0 = p; + float3 pp = p + fmod(time, 2.0f) * 0.35f; + p *= 0.3f; + + for (int i = 0; i < 4; ++i) { + p = 0.7f * abs(p) / max(dot(p, p), 0.18f) - 0.95f; + p.yz = ssComplexSquare(p.yz); + p = p.zxy; + } + + p = pp + p * 0.5f; + float d = 0.0f; + float seed = ssSeed(time); + + for (int i = 0; i < 6; ++i) { + float fi = float(i); + float3 hash = ssHash33(float3(fi, seed, 1.0f)); + float3 axis = normalize(hash * float3(2.0f, 4.0f, 2.0f) - float3(1.0f, 1.0f, 1.0f)); + float3 offset = float3(0.0f, ssHash11(fi + 71.0f + seed) * 2.0f - 1.0f, 0.0f); + float proj = dot(p - offset, axis); + d += smoothstep(0.1f, 0.0f, abs(proj)); + } + + float pulse = ssSaturate(1.0f - length(p0 * (1.0f + sin(time * 2.0f) * 0.5f))); + d = d * 0.5f + pulse * (0.75f + d * 0.25f); + return d * d + 0.05f; +} + +static SoulstoneIntersection ssRaymarch(float3 ro, float3 rd, float time) { + SoulstoneIntersection outData; + outData.sdf = 0.0f; + outData.materialID = SOULSTONE_MATERIAL_NONE; + outData.density = 0.0f; + outData.totalDistance = 0.0f; + outData.mediumDistance = 0.0f; + outData.hit = false; + + for (int j = 0; j < SOULSTONE_MAX_STEPS; ++j) { + float3 p = ro + rd * outData.totalDistance; + outData.sdf = ssCrystalSDFSimple(p, time) * 0.9f; + outData.totalDistance += outData.sdf; + + if (outData.sdf < SOULSTONE_EPSILON || outData.totalDistance > SOULSTONE_MAX_DISTANCE) { + break; + } + } + + if (outData.sdf < SOULSTONE_EPSILON && outData.totalDistance <= SOULSTONE_MAX_DISTANCE) { + float t = SOULSTONE_FIXED_STEP_SIZE; + float d = 0.0f; + float3 hitPosition = ro + rd * (outData.totalDistance + SOULSTONE_FIXED_STEP_SIZE); + + float3 normal = ssCrystalNormal(hitPosition, 1.0f, time); + float3 refr = refract(rd, normal, 0.9f); + if (length_squared(refr) < 1.0e-6f) { + refr = reflect(rd, normal); + } + + for (int i = 0; i < 50; ++i) { + float3 p = hitPosition + refr * t; + if (ssCrystalSDFSimple(p, time) > SOULSTONE_EPSILON) { + break; + } + + d += ssDensity(p, time); + t += SOULSTONE_FIXED_STEP_SIZE; + } + + outData.density = d; + outData.materialID = SOULSTONE_MATERIAL_CRYSTAL; + outData.totalDistance *= 0.99f; + outData.mediumDistance = t; + outData.hit = true; + } + + return outData; +} + +static float3 ssGradient(float factor) { + float3 a = float3(0.478f, 0.45f, 0.5f); + float3 b = float3(0.5f); + float3 c = float3(0.1688f, 0.748f, 0.1748f); + float3 d = float3(0.1318f, 0.388f, 0.1908f); + return ssPalette(factor, a, b, c, d); +} + +fragment float4 soulstoneFragment( + SoulstoneVertexOut in [[stage_in]], + constant SoulstoneUniforms &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); + float3 camRight = normalize(float3(v2w[0].x, v2w[0].y, v2w[0].z)); + float3 camUp = normalize(float3(v2w[1].x, v2w[1].y, v2w[1].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) < SOULSTONE_BOX_HALF - 1.0e-3f); + float2 tBox = ssBoxIntersect(eye, rd, SOULSTONE_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 + SOULSTONE_EPSILON)) * 2.05f; + ro = ssRotateY(ro, uniforms.time * 0.22f); + ro = ssRotateX(ro, sin(uniforms.time * 0.31f) * 0.22f); + float3 marchDir = ssRotateY(rd, uniforms.time * 0.22f); + marchDir = ssRotateX(marchDir, sin(uniforms.time * 0.31f) * 0.22f); + + SoulstoneIntersection isect = ssRaymarch(ro, marchDir, uniforms.time); + if (isect.materialID > 0) { + float3 p = ro + marchDir * isect.totalDistance; + float3 lightPos = ro - camRight * 2.0f + camUp * 2.0f; + float3 normal = ssCrystalNormal(p, SOULSTONE_EPSILON_NORMAL, uniforms.time); + float3 toLight = normalize(lightPos - p); + + float3 tx = ssTriplanar(p * 0.85f - p.zzz * 0.3f, normal); + float curvature = ssCurvatureModifier(p, 0.1f + tx.r * 0.85f, uniforms.time); + normal = normalize(normal - float3(curvature * 0.3f) + (tx * 0.25f - 0.125f)); + + float rim = pow(smoothstep(0.0f, 1.0f, 1.0f - dot(normal, -marchDir)), 7.0f); + float3 H = normalize(toLight - marchDir); + float specular = pow(max(0.0f, dot(H, normal)), tx.r * 5.0f + curvature * 25.0f); + + float3 reflected = reflect(marchDir, normal); + float3 refl = ssEnvironment(reflected); + + float glowFactor = isect.density * 0.04f; + float3 glow = mix(float3(1.0f, 0.15f, 0.15f), float3(1.0f, 0.45f, 0.15f), isect.density * 0.05f) * glowFactor; + glow *= smoothstep(0.5f, 1.0f, curvature) * 1.5f + 1.0f; + glow *= 1.0f + pow(exp(-isect.mediumDistance), 2.0f) * 4.0f; + + float transmission = exp(-isect.mediumDistance * 0.35f); + float3 soulGlow = ssGradient(isect.density * 0.04f + transmission * 0.2f) * isect.density * 0.018f; + float2 glowUV = ssFaceUV(hit) * 2.0f - 1.0f; + glowUV.y += sin(uniforms.time * 2.0f) * 0.1f; + float3 glowColor = float3(1.0f, 0.7f, 0.15f); + float2 scaled = glowUV * 0.7f; + float3 fx = glowColor * pow(ssSaturate(1.0f - length(scaled * float2(0.75f, 0.9f))), 2.0f); + fx += glowColor * pow(ssSaturate(1.0f - length(scaled * float2(0.5f, 1.0f))), 2.0f); + fx += glowColor * pow(ssSaturate(1.0f - length(scaled * float2(0.25f, 7.0f))), 2.0f) * 0.25f; + fx += glowColor * pow(ssSaturate(1.0f - length(scaled * float2(0.1f, 7.0f))), 2.0f) * 0.15f; + float intensity = pow(ssProceduralTexture(float2(uniforms.time * 0.03f, 0.0f)).r, 2.0f); + + float3 color = (refl + specular) * float3(0.15f, 0.1f, 0.1f) * rim; + color += rim * curvature * 0.15f * float3(0.1f, 0.4f, 0.8f); + color += glow + soulGlow + fx * fx * fx * intensity * 0.2f; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); + } + + float2 uv = ssFaceUV(hit) * 2.0f - 1.0f; + float vignette = 1.0f - pow(length(uv + ssHash31(ro + float3(uniforms.time)) * 0.2f) / 2.0f, 2.0f); + float3 bg = float3(0.15f, 0.025f, 0.1f) * vignette * vignette * 0.25f; + bg += ssEnvironment(marchDir) * 0.18f; + return float4(clamp(bg, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Soulstone/SoulstoneTypes.swift b/vr-dive/Demos/Soulstone/SoulstoneTypes.swift new file mode 100644 index 0000000..cd3ef5b --- /dev/null +++ b/vr-dive/Demos/Soulstone/SoulstoneTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct SoulstoneUniforms in SoulstoneShaders.metal. +struct SoulstoneUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/SpiraledLayers/SpiraledLayersRenderer.swift b/vr-dive/Demos/SpiraledLayers/SpiraledLayersRenderer.swift new file mode 100644 index 0000000..1f35eda --- /dev/null +++ b/vr-dive/Demos/SpiraledLayers/SpiraledLayersRenderer.swift @@ -0,0 +1,168 @@ +import Metal +import simd + +// SpiraledLayersRenderer.swift +// +// Source reference: +// https://www.shadertoy.com/view/Ns3XWf +// "Spiraled Layers" shader (original author unknown) +// License: see original ShaderToy page + +final class SpiraledLayersRenderer: VisualPatternController { + let identifier: VisualPatternKind = .spiraledLayers + 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 = SpiraledLayersRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SpiraledLayersRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = SpiraledLayersRenderer.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 = SpiraledLayersUniforms( + 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 SpiraledLayersRenderer { + 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: "spiraledLayersVertex") + desc.fragmentFunction = library.makeFunction(name: "spiraledLayersFragment") + 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/SpiraledLayers/SpiraledLayersShaders.metal b/vr-dive/Demos/SpiraledLayers/SpiraledLayersShaders.metal new file mode 100644 index 0000000..edee861 --- /dev/null +++ b/vr-dive/Demos/SpiraledLayers/SpiraledLayersShaders.metal @@ -0,0 +1,346 @@ +// SpiraledLayersShaders.metal +// +// Source reference: +// https://www.shadertoy.com/view/Ns3XWf +// "Spiraled Layers" shader (original author unknown) +// 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. +// Each ray is box-intersected with the cube, then marched through the scene in local space +// scaled by SPL_SCENE_SCALE. A +3 scene-x offset centres the spiral content in the cube. + +#include +using namespace metal; + +// ── Tuning constants ────────────────────────────────────────────────────────── +#define SPL_PI 3.14159265358979323846f +#define SPL_STEPS 100 // ray march iterations (original: 200) +#define SPL_SHAD_STEPS 8 // soft-shadow iterations (was 16, halved for perf) +#define SPL_MDIST 30.0f // max march distance in scene units +#define SPL_SCENE_SCALE 5.0f // local [-1,1] → scene units; matches original apparent scale + +// ── Structs ─────────────────────────────────────────────────────────────────── +struct SpiraledLayersUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SpiraledLayersVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ── Vertex shader ───────────────────────────────────────────────────────────── +vertex SpiraledLayersVertexOut spiraledLayersVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SpiraledLayersUniforms &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; + + SpiraledLayersVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ── Box intersection (slab method; handles inside-box case) ─────────────────── +static bool spl_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); +} + +// ── Scene helpers (ported 1-to-1 from GLSL) ────────────────────────────────── + +// rot(a) = mat2(cos,sin,-sin,cos); used as p *= rot(a) (row-vector × matrix → rotate by -a) +static float2x2 spl_rot(float a) { + float c = cos(a), s = sin(a); + return float2x2(float2(c, s), float2(-s, c)); +} + +// Extruded 1-D SDF: extrude line SDF 's' into a slab of half-height h along p.y +static float spl_ext(float3 p, float s, float h) { + float2 b = float2(s, abs(p.y) - h); + return min(max(b.x, b.y), 0.0f) + length(max(b, 0.0f)); +} + +// Pseudo-random hash (original: h11) +static float spl_h11(float a) { + a += 0.65343f; + return fract(fract(a * a * 12.9898f) * 43758.5453123f); +} + +// Distance to next lane-boundary plane along the ray; returns large value when +// rd.z ≈ 0 to prevent divide-by-zero (ray parallel to boundary → no artifact) +static float spl_diplane(float3 p, float3 b, float3 rd) { + if (abs(rd.z) < 1e-6f) return 1e6f; + float3 dir = sign(rd) * b; + float3 rc = (dir - p) / rd; + return rc.z + 0.01f; +} + +// Tiled repetition clamped to [lima, limb] cells +static float spl_lim(float p, float s, float lima, float limb) { + return p - s * clamp(round(p / s), lima, limb); +} +static float spl_idlim(float p, float s, float lima, float limb) { + return clamp(round(p / s), lima, limb); +} +static float spl_lim2(float p, float s, float limb) { + return p - s * min(round(p / s), limb); +} +static float spl_idlim2(float p, float s, float limb) { + return min(round(p / s), limb); +} + +// Spiral SDF — port of spiral() from ShaderToy Ns3XWf +static float spl_spiral(float2 p, float t, float m, float scale, float size, float expand) { + size -= expand - 0.01f; + t = max(t, 0.0f); + + // Offset spiral to the left + p.x += SPL_PI * -t * (m + m * (-t - 1.0f)); + t -= 0.25f; + + float2 po = p; + + // Move spiral up and counter-rotate + p.y += -t * m - m * 0.5f; + p = p * spl_rot(t * SPL_PI * 2.0f + SPL_PI * 0.5f); + + // Polar map + float theta = atan2(p.y, p.x); + theta = clamp(theta, -SPL_PI, SPL_PI); + p = float2(theta, length(p)); + + // Create spiral: offset radial by angle + p.y += theta * scale * 0.5f; + + // Tile spiral rings + float py = p.y; + p.y = spl_lim(p.y, m, 0.0f, floor(t)); + + // Line SDF of the spiral + float a = abs(p.y) - size; + + // Moving outer spiral segment + p.y = py; + p.x -= SPL_PI; + p.y -= (floor(t) + 1.5f) * m - m * 0.5f; + float b = max(abs(p.y), abs(p.x) - (SPL_PI * 2.0f) * fract(t) + size); + + // Unrolled line SDF + a = min(a, b - size); + b = abs(po.y) - size; + b = max(po.x * 30.0f, b); + + a = min(a, b); + return a; +} + +// Scene SDF — port of map() from ShaderToy Ns3XWf +// Returns float3(distance, id_flag, c_for_AO_and_soft_shadow) +// id_flag = 1 → real spiral surface; 0 → lane-boundary artifact plane +// c_for_AO = SDF value without boundary clipping (used for soft shadows / AO) +// rdg: active ray direction needed by spl_diplane for artifact removal +static float3 spl_map(float3 p, float iTime, float3 rdg) { + float2 a = float2(1.0f); + float2 b = float2(1.0f); + float c = 0.0f; + float t = iTime; + + const float size = 0.062f; + const float scale = size - 0.01f; // 0.052 + const float expand = 0.04f; + const float m2 = size * 6.0f; + const float m = SPL_PI * scale; // ≈ 0.1634 + const float ltime = 10.0f; + const float width = 0.5f; + const float count = 3.0f; // reduced from 6 → 7 lanes instead of 13 + const float modwidth = width * 2.0f + 0.10f; // ≈ 1.1 + + // Scroll upward with time; offset in x to frame the scene + p.y -= (t / ltime) * size * 6.0f; + p.x -= 3.0f; + + // Tile in z; randomise per-lane timing + float id3 = spl_idlim(p.z, modwidth, -count, count); + t += spl_h11(id3 * 0.76f) * 8.0f; + p.z = spl_lim(p.z, modwidth, -count, count); + + float to = t; + float3 po = p; + + // ── Spiral 1 ── + float stack = -floor(t / ltime); + float id2 = spl_idlim2(p.y, m2, stack); + t += id2 * ltime; + p.y = spl_lim2(p.y, m2, stack); + a.x = spl_spiral(p.xy, t, m, scale, size, expand); + c = a.x; + a.x = min(a.x, max(p.y + size * 5.0f, p.x)); // artifact removal + + // ── Spiral 2 ── + p = po; t = to; + p.y += size * 2.0f; + t -= ltime / 3.0f; + stack = -floor(t / ltime); + id2 = spl_idlim2(p.y, m2, stack); + t += id2 * ltime; + p.y = spl_lim2(p.y, m2, stack); + b.x = spl_spiral(p.xy, t, m, scale, size, expand); + c = min(c, b.x); + a = (a.x < b.x) ? a : b; + a.x = min(a.x, max(p.y + size * 5.0f, p.x)); // artifact removal + + // ── Spiral 3 ── + p = po; t = to; + p.y += size * 4.0f; + t -= 2.0f * ltime / 3.0f; + stack = -floor(t / ltime); + id2 = spl_idlim2(p.y, m2, stack); + t += id2 * ltime; + p.y = spl_lim2(p.y, m2, stack); + b.x = spl_spiral(p.xy, t, m, scale, size, expand); + c = min(c, b.x); + a = (a.x < b.x) ? a : b; + a.x = min(a.x, max(p.y + size * 5.0f, p.x)); // artifact removal + + // Extrude spirals in z (lane direction). po.yzx = (po.y, po.z, po.x). + float halfLane = width - expand * 0.5f + 0.02f; + a.x = spl_ext(float3(po.y, po.z, po.x), a.x, halfLane) - expand; + c = spl_ext(float3(po.y, po.z, po.x), c, halfLane) - expand; + + // Lane-boundary artifact removal via diplane + b.x = spl_diplane(po, float3(modwidth) * 0.5f, rdg); + b.y = 0.0f; + a = (a.x < b.x) ? a : b; + + return float3(a, c); +} + +// Surface normal via forward-difference gradient. +// hitDist: spl_map(p).x already computed at the hit point — passed in to avoid +// a redundant 4th spl_map call. Finer epsilon gives sharper normals. +static float3 spl_norm(float3 p, float iTime, float3 rdg, float hitDist) { + const float e = 0.005f; // finer than 0.01 → more accurate normals on thin spirals + return normalize(hitDist - float3( + spl_map(p - float3(e, 0.0f, 0.0f), iTime, rdg).x, + spl_map(p - float3(0.0f, e, 0.0f), iTime, rdg).x, + spl_map(p - float3(0.0f, 0.0f, e), iTime, rdg).x)); +} + +// ── Fragment shader ─────────────────────────────────────────────────────────── +fragment float4 spiraledLayersFragment( + SpiraledLayersVertexOut in [[stage_in]], + constant SpiraledLayersUniforms &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 (!spl_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tNear, tFar)) { + discard_fragment(); + } + float tStart = max(tNear, 0.0f); + + // Map to scene space. +1 in x shifts spiral content rightward so it fills + // from centre to ~86 % across, leaving ~14 % right margin (original was +3 + // which centred the spiral exactly, leaving the entire right half empty). + const float3 SCENE_OFFSET = float3(1.0f, 0.0f, 0.0f); + float3 ro = roLocal * SPL_SCENE_SCALE + SCENE_OFFSET; + float3 rd = normalize(rdLocal); + float tMaxScene = min(tFar, 100.0f) * SPL_SCENE_SCALE; + + // Slow animation to 1/5 of accumulated time + float iTime = uniforms.time * 0.2f; + float3 rdg = rd; // diplane artifact-removal direction tracks the active marching ray + + // ── Ray march ── + float3 p = ro; + float3 d = float3(0.0f); + float dO = tStart * SPL_SCENE_SCALE; + bool hit = false; + + for (int i = 0; i < SPL_STEPS; ++i) { + p = ro + rd * dO; + d = spl_map(p, iTime, rdg); + dO += d.x; + if (d.x < 0.001f) { hit = true; break; } + if (dO > SPL_MDIST || dO > tMaxScene) break; + } + + float3 col; + + if (hit && d.y != 0.0f) { + // ── Lighting ── + float3 ld = normalize(float3(0.5f, 0.4f, 0.9f)); + // Pass d.x (already known at hit) so spl_norm skips one redundant spl_map call. + float3 n = spl_norm(p, iTime, rdg, d.x); + + // Soft shadow (rdg updated to ld so diplane uses light direction) + rdg = ld; + float shadow = 1.0f; + float h = 0.09f; + for (int i = 0; i < SPL_SHAD_STEPS; ++i) { + float3 dd = spl_map(p + ld * h + n * 0.005f, iTime, rdg); + if (dd.x < 0.001f && dd.y == 0.0f) break; // lane boundary — not a real occluder + if (dd.x < 0.001f) { shadow = 0.0f; break; } // real surface blocks light + shadow = min(shadow, dd.z * 30.0f); + if (h > 7.0f) break; + h += dd.x; + } + shadow = max(shadow, 0.8f); + + // AO: single-sample smoothstep (saves one spl_map call vs the original + // two-level multiply; visually indistinguishable on thin spiral ribbons). + float ao = max(smoothstep(-0.08f, 0.08f, spl_map(p + n * 0.08f, iTime, rdg).z), 0.1f); + + // Normal-based colour with hue rotation in xz plane (matches original n.xz *= rot(4π/3)) + n.xz = n.xz * spl_rot(4.0f * SPL_PI / 3.0f); + col = n * 0.5f + 0.5f; + col = col * shadow * ao; + + } else { + // Background: vertical gradient (purple-blue sky, matches original render()) + col = mix(float3(0.355f, 0.129f, 0.894f), + float3(0.278f, 0.953f, 1.000f), + clamp((rd.y + 0.05f) * 2.0f, -0.15f, 1.5f)); + } + + // Gamma ≈ 2.0 approximation (matches original sqrt(col)) + col = sqrt(max(col, 0.0f)); + return float4(col, 1.0f); +} diff --git a/vr-dive/Demos/SpiraledLayers/SpiraledLayersTypes.swift b/vr-dive/Demos/SpiraledLayers/SpiraledLayersTypes.swift new file mode 100644 index 0000000..143a579 --- /dev/null +++ b/vr-dive/Demos/SpiraledLayers/SpiraledLayersTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct SpiraledLayersUniforms in +/// SpiraledLayersShaders.metal. +struct SpiraledLayersUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/StarTrails/StarTrailsRenderer.swift b/vr-dive/Demos/StarTrails/StarTrailsRenderer.swift new file mode 100644 index 0000000..68111b7 --- /dev/null +++ b/vr-dive/Demos/StarTrails/StarTrailsRenderer.swift @@ -0,0 +1,172 @@ +import Metal +import simd + +// StarTrailsRenderer.swift +// +// Renders a 2 m cube filled with glowing circular star-trail orbits. +// Architecture follows GlassBoxRenderer: a bounding-box mesh acts as the +// geometry container; the fragment shader does all volumetric glow work. +// +// Box half-extents = 1 m (world space), centred at objectCenter. + +final class StarTrailsRenderer: VisualPatternController { + let identifier: VisualPatternKind = .starTrails + 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, centred 1.5 m in front of the user. + 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) + + // Build a box mesh slightly larger than the logical 1 m half-extents so + // rasterised faces fully enclose all visible volume pixels. + let geo = StarTrailsRenderer.makeBox( + device: device, + halfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try StarTrailsRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = StarTrailsRenderer.makeDepthStencilState(device: device) + } + + func updateSimulation(_ context: PatternSimulationContext) { + defer { lastSimulationTime = context.time } + guard let lastSimulationTime else { return } + let delta = max(0, min(context.time - lastSimulationTime, 1.0 / 20.0)) + animationTime += delta * max(context.speedMultiplier, 0) + } + + func resetToInitialState() { + animationTime = 0 + lastSimulationTime = nil + } + + func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) { + encoder.setRenderPipelineState(pipelineState) + encoder.setDepthStencilState(depthStencilState) + // .none so the box is visible both from outside and when the user steps inside. + encoder.setCullMode(.none) + context.applyViewConfiguration(on: encoder) + + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + + var uniforms = StarTrailsUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + 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) + } +} + +// MARK: - Geometry & pipeline helpers +extension StarTrailsRenderer { + + /// Builds a box mesh with outward normals. 6 faces × 4 verts × CCW winding. + fileprivate static func makeBox( + device: MTLDevice, + halfExtents e: SIMD3 + ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) { + typealias V = STMeshVertex + 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]), // +Z + ([[x, -y, -z], [-x, -y, -z], [-x, y, -z], [x, y, -z]], [0, 0, -1]), // -Z + ([[x, -y, z], [x, -y, -z], [x, y, -z], [x, y, z]], [1, 0, 0]), // +X + ([[-x, -y, -z], [-x, -y, z], [-x, y, z], [-x, y, -z]], [-1, 0, 0]), // -X + ([[-x, y, z], [x, y, z], [x, y, -z], [-x, y, -z]], [0, 1, 0]), // +Y + ([[-x, -y, -z], [x, -y, -z], [x, -y, z], [-x, -y, z]], [0, -1, 0]), // -Y + ] + var verts: [V] = [] + verts.reserveCapacity(24) + var indices: [UInt16] = [] + indices.reserveCapacity(36) + for face in faces { + let base = UInt16(verts.count) + for p in face.positions { verts.append(V(position: p, normal: face.normal)) } + indices.append(contentsOf: [base, base + 1, base + 2, base, base + 2, base + 3]) + } + let vBuf = device.makeBuffer( + bytes: verts, length: MemoryLayout.stride * verts.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: "starTrailsVertex") + desc.fragmentFunction = library.makeFunction(name: "starTrailsFragment") + 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 // reverse-Z + desc.isDepthWriteEnabled = true + return device.makeDepthStencilState(descriptor: desc)! + } +} + +// MARK: - Private vertex type +// Named STMeshVertex to avoid collision with the global MeshVertex in RendererTypes.swift. +private struct STMeshVertex { + var position: SIMD3 + var normal: SIMD3 +} diff --git a/vr-dive/Demos/StarTrails/StarTrailsShaders.metal b/vr-dive/Demos/StarTrails/StarTrailsShaders.metal new file mode 100644 index 0000000..04d65a0 --- /dev/null +++ b/vr-dive/Demos/StarTrails/StarTrailsShaders.metal @@ -0,0 +1,248 @@ +// StarTrailsShaders.metal +// +// Star-trail long-exposure effect: particles orbit the Z-axis inside a 2 m +// cube, each leaving a short curved arc behind them. +// +// Performance design: +// - Integer Murmur3 hash (no sin() in hash) +// - Ray pre-rotated into star-field frame once — only 1 cos/sin per pixel +// - Very tight distance cutoff keeps the halo narrow and the lines crisp +// - Each arc is approximated by 3 short line segments; this preserves the +// circular silhouette without going back to volumetric ray marching +// - No atan2 or ray-march loop on the hot path + +#include +using namespace metal; + +// ─── Structs ───────────────────────────────────────────────────────────────── + +// Layout must match StarTrailsUniforms in StarTrailsTypes.swift. +struct StarTrailsUniforms { + float time; + uint viewCount; + float pad0; + float pad1; + float4 objectCenter; // xyz = world-space box centre +}; + +struct STVertex { + float3 position; + float3 normal; +}; + +struct STVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// ─── Vertex shader ──────────────────────────────────────────────────────────── +vertex STVertexOut starTrailsVertex( + ushort amplificationID [[amplification_id]], + const device STVertex *vertices [[buffer(0)]], + constant StarTrailsUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + STVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position + uniforms.objectCenter.xyz; + + STVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// Fast integer hash (Murmur3 finalizer), result in [0, 1). +// No transcendental functions — replacing the sin()-based hash cuts ~20 k +// sin() calls per pixel at the original orbit/step counts. +static float st_h(uint n) { + n ^= (n >> 16u); + n *= 0x85ebca6bu; + n ^= (n >> 13u); + n *= 0xc2b2ae35u; + n ^= (n >> 16u); + return float(n) * (1.0f / 4294967296.0f); +} + +// Ray vs axis-aligned box. Sets tNear/tFar and returns true on hit. +// IEEE infinity handles zero ray-direction components correctly. +static bool st_boxHit(float3 ro, float3 rd, float3 halfExtents, + thread float &tNear, thread float &tFar) +{ + float3 invRd = 1.0f / rd; // ±inf for zero components is intentional + float3 t1 = (-halfExtents - ro) * invRd; + float3 t2 = ( halfExtents - ro) * invRd; + float3 tMin = min(t1, t2); + float3 tMax = max(t1, t2); + tNear = max(max(tMin.x, tMin.y), tMin.z); + tFar = min(min(tMax.x, tMax.y), tMax.z); + if (tNear > tFar || tFar < 0.0f) return false; + tNear = max(tNear, 0.0f); + return true; +} + +// HSV → RGB. +static float3 st_hsv2rgb(float h, float s, float v) { + float3 c = clamp(abs(fract(h + float3(1.0f, 2.0f/3.0f, 1.0f/3.0f)) * 6.0f - 3.0f) - 1.0f, + 0.0f, 1.0f); + return v * mix(float3(1.0f), c, s); +} + +// Closest distance between a ray ro + rd * t (t >= 0) and a segment [a, b]. +// Returns squared distance and outputs the closest ray/segment parameters. +static float st_raySegmentDistanceSq( + float3 ro, float3 rd, float3 a, float3 b, + thread float &rayT, thread float &segU) +{ + float3 ab = b - a; + float abLen2 = max(dot(ab, ab), 1e-6f); + float3 ao = ro - a; + float rdAb = dot(rd, ab); + float rdAo = dot(rd, ao); + float abAo = dot(ab, ao); + float denom = abLen2 - rdAb * rdAb; + + float u; + if (denom > 1e-6f) { + u = clamp((abAo - rdAb * rdAo) / denom, 0.0f, 1.0f); + rayT = rdAb * u - rdAo; + } else { + u = clamp(abAo / abLen2, 0.0f, 1.0f); + rayT = dot(a + ab * u - ro, rd); + } + + if (rayT < 0.0f) { + rayT = 0.0f; + u = clamp(abAo / abLen2, 0.0f, 1.0f); + } else { + float3 q = ro + rd * rayT; + u = clamp(dot(q - a, ab) / abLen2, 0.0f, 1.0f); + rayT = max(dot(a + ab * u - ro, rd), 0.0f); + } + + segU = u; + float3 diff = ro + rd * rayT - (a + ab * u); + return dot(diff, diff); +} + +// ─── Fragment shader ────────────────────────────────────────────────────────── +fragment float4 starTrailsFragment( + STVertexOut in [[stage_in]], + constant StarTrailsUniforms &uniforms [[buffer(0)]], + constant float4x4 *v2wMats [[buffer(1)]]) +{ + uint vi = min(in.viewIndex, uniforms.viewCount - 1u); + float4x4 v2w = v2wMats[vi]; + float3 cam = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 center = uniforms.objectCenter.xyz; + + // Ray in box-local space (box half-extents = 1 m). + float3 ro = cam - center; + float3 rd = normalize(in.worldPos - cam); + + const float3 BOX_HALF = float3(1.0f); + float tNear, tFar; + if (!st_boxHit(ro, rd, BOX_HALF, tNear, tFar)) discard_fragment(); + + const int N_ORBITS = 28; + const int ARC_SEGMENTS = 3; + const float D_CUTOFF = 0.016f; // 1.6 cm line radius cutoff + const float D_CUTOFF2 = D_CUTOFF * D_CUTOFF; + const float TWO_PI = 6.28318f; + float3 accumColor = float3(0.0f); + + for (int i = 0; i < N_ORBITS; ++i) { + uint base = uint(i) * 8u; + float h1 = st_h(base + 0u); // radius + float h2 = st_h(base + 1u); // z base + float h3 = st_h(base + 2u); // trail angle / colour split + float h4 = st_h(base + 3u); // angular velocity + float h5 = st_h(base + 4u); // initial phase + float h6 = st_h(base + 5u); // z wobble amplitude + float h7 = st_h(base + 6u); // z wobble frequency + float h8 = st_h(base + 7u); // colour / brightness variant + + float radius = 0.16f + 0.58f * h1; + float zBase = -0.70f + 1.40f * h2; + float omega = 0.22f + 0.48f * h4; + float angle = omega * uniforms.time + h5 * TWO_PI; + float zAmp = 0.02f + 0.09f * h6; + float zFreq = 1.0f + floor(h7 * 2.99f); + float wave = angle * zFreq + h8 * TWO_PI; + float trailAngle = 0.22f + 0.24f * h3; + float subAngle = trailAngle / float(ARC_SEGMENTS); + + float sA = sin(angle); + float cA = cos(angle); + float sW = sin(wave); + float cW = cos(wave); + float cStep = cos(subAngle); + float sStep = sin(subAngle); + float waveStep = subAngle * zFreq; + float cWaveStep = cos(waveStep); + float sWaveStep = sin(waveStep); + + float3 col; + if (h3 < 0.52f) { + col = st_hsv2rgb(0.58f + 0.11f * h1, 0.10f + 0.40f * h6, 1.0f); + } else if (h3 < 0.84f) { + col = st_hsv2rgb(0.08f + 0.10f * h8, 0.08f + 0.34f * h2, 1.0f); + } else { + float hue = (h8 < 0.5f) ? (0.48f + 0.06f * h1) : (0.76f + 0.05f * h1); + col = st_hsv2rgb(hue, 0.55f + 0.35f * h6, 1.0f); + } + + float brightness = 0.75f + 3.3f * h8 * h8; + + float currC = cA; + float currS = sA; + float currCW = cW; + float currSW = sW; + float3 p0 = float3(radius * currC, radius * currS, zBase + zAmp * currSW); + + float bestGlow = 0.0f; + float bestRayT = tFar; + float bestAlong = 1.0f; + + for (int seg = 0; seg < ARC_SEGMENTS; ++seg) { + float nextC = currC * cStep + currS * sStep; + float nextS = currS * cStep - currC * sStep; + float nextCW = currCW * cWaveStep + currSW * sWaveStep; + float nextSW = currSW * cWaveStep - currCW * sWaveStep; + float3 p1 = float3(radius * nextC, radius * nextS, zBase + zAmp * nextSW); + + float rayT; + float segU; + float d2 = st_raySegmentDistanceSq(ro, rd, p0, p1, rayT, segU); + if (rayT >= tNear && rayT <= tFar && d2 <= D_CUTOFF2) { + float glow = exp(-d2 * 110000.0f) + exp(-d2 * 16000.0f) * 0.05f; + if (glow > bestGlow) { + bestGlow = glow; + bestRayT = rayT; + bestAlong = (float(seg) + segU) / float(ARC_SEGMENTS); + } + } + + currC = nextC; + currS = nextS; + currCW = nextCW; + currSW = nextSW; + p0 = p1; + } + + if (bestGlow == 0.0f) continue; + + float trailFade = 0.24f + 0.76f * pow(1.0f - bestAlong, 0.45f); + float depthFade = exp(-(bestRayT - tNear) * 0.42f); + accumColor += col * brightness * bestGlow * trailFade * depthFade; + } + + float3 mapped = 1.0f - exp(-accumColor * 2.6f); + mapped = max(mapped, float3(0.002f, 0.001f, 0.004f)); + return float4(mapped, 1.0f); +} diff --git a/vr-dive/Demos/StarTrails/StarTrailsTypes.swift b/vr-dive/Demos/StarTrails/StarTrailsTypes.swift new file mode 100644 index 0000000..30f15c7 --- /dev/null +++ b/vr-dive/Demos/StarTrails/StarTrailsTypes.swift @@ -0,0 +1,10 @@ +import simd + +// Layout must match StarTrailsUniforms in StarTrailsShaders.metal. +struct StarTrailsUniforms { + var time: Float + var viewCount: UInt32 + var pad0: Float = 0 + var pad1: Float = 0 + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/StarryPlanes/StarryPlanesRenderer.swift b/vr-dive/Demos/StarryPlanes/StarryPlanesRenderer.swift new file mode 100644 index 0000000..01484b6 --- /dev/null +++ b/vr-dive/Demos/StarryPlanes/StarryPlanesRenderer.swift @@ -0,0 +1,178 @@ +import Metal +import simd + +// StarryPlanesRenderer.swift +// +// Cube-container adaptation of ShaderToy "Starry planes" (MfjyWK). +// The visible container is a 2 m × 2 m × 2 m cube. Rays start on the visible +// cube face when viewed from outside, or at the viewer position when the camera +// is inside the cube. + +final class StarryPlanesRenderer: VisualPatternController { + let identifier: VisualPatternKind = .starryPlanes + 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 = StarryPlanesRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try StarryPlanesRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = StarryPlanesRenderer.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.6 + } + + 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 = StarryPlanesUniforms( + 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 StarryPlanesRenderer { + 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: "starryPlanesVertex") + desc.fragmentFunction = library.makeFunction(name: "starryPlanesFragment") + 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/StarryPlanes/StarryPlanesShaders.metal b/vr-dive/Demos/StarryPlanes/StarryPlanesShaders.metal new file mode 100644 index 0000000..72f5bd5 --- /dev/null +++ b/vr-dive/Demos/StarryPlanes/StarryPlanesShaders.metal @@ -0,0 +1,292 @@ +// StarryPlanesShaders.metal +// "Starry planes" — cube-container adaptation of ShaderToy MfjyWK. +// Source: https://www.shadertoy.com/view/MfjyWK +// Original shader is marked CC0. This adaptation preserves the core stacked +// plane-marcher idea, star-shaped masks, curved path offsets, and ACES-like tone +// mapping while replacing the screen-space flythrough camera with a real per-eye +// ray that enters a visible 2 m cube container. + +#include +using namespace metal; + +struct StarryPlanesUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct StarryPlanesVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float SP_PI = 3.14159265359f; +static constant float SP_PLANE_DIST = 0.5f; +static constant float SP_FURTHEST = 16.0f; +static constant float SP_FADE_FROM = 8.0f; +static constant float2 SP_PATH_A = float2(0.31f, 0.41f); +static constant float2 SP_PATH_B = float2(1.0f, 0.70710678f); +static constant float3 SP_BOX_HALF = float3(1.0f); + +static float2 spRotate(float2 p, float angle) { + float c = cos(angle); + float s = sin(angle); + return float2(c * p.x + s * p.y, -s * p.x + c * p.y); +} + +static float3 acesApprox(float3 v) { + v = max(v, 0.0f); + v *= 0.6f; + float a = 2.51f; + float b = 0.03f; + float c = 2.43f; + float d = 0.59f; + float e = 0.14f; + return clamp((v * (a * v + b)) / (v * (c * v + d) + e), 0.0f, 1.0f); +} + +static float3 offsetCurve(float z) { + return float3(SP_PATH_B * sin(SP_PATH_A * z), z); +} + +static float3 dOffsetCurve(float z) { + return float3(SP_PATH_A * SP_PATH_B * cos(SP_PATH_A * z), 1.0f); +} + +static float3 ddOffsetCurve(float z) { + return float3(-SP_PATH_A * SP_PATH_A * SP_PATH_B * sin(SP_PATH_A * z), 0.0f); +} + +static float4 alphaBlend(float4 back, float4 front) { + float w = front.w + back.w * (1.0f - front.w); + if (w <= 0.0f) { + return float4(0.0f); + } + float3 xyz = (front.xyz * front.w + back.xyz * back.w * (1.0f - front.w)) / w; + return float4(xyz, w); +} + +static float pmin(float a, float b, float k) { + float h = clamp(0.5f + 0.5f * (b - a) / k, 0.0f, 1.0f); + return mix(b, a, h) - k * h * (1.0f - h); +} + +static float pabs(float a, float k) { + return -pmin(a, -a, k); +} + +static float star5(float2 p, float r, float rf, float sm) { + p = -p; + const float2 k1 = float2(0.809016994375f, -0.587785252292f); + const float2 k2 = float2(-0.809016994375f, -0.587785252292f); + p.x = abs(p.x); + p -= 2.0f * max(dot(k1, p), 0.0f) * k1; + p -= 2.0f * max(dot(k2, p), 0.0f) * k2; + p.x = pabs(p.x, sm); + p.y -= r; + float2 ba = rf * float2(-k1.y, k1.x) - float2(0.0f, 1.0f); + float h = clamp(dot(p, ba) / dot(ba, ba), 0.0f, r); + return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y); +} + +static float3 palette(float n) { + return 0.5f + 0.5f * sin(float3(0.0f, 1.0f, 2.0f) + n); +} + +static float2 spBoxIntersect(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 spFaceUV(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 float4 planeColor( + float3 ro, + float3 rd, + float3 pp, + float3 npp, + float pd, + float planeIndex +) { + float aa = 3.0f * pd * distance(pp.xy, npp.xy); + float2 p2 = pp.xy - offsetCurve(pp.z).xy; + float2 doff = ddOffsetCurve(pp.z).xz; + float2 ddoff = dOffsetCurve(pp.z).xz; + float dd = dot(doff, ddoff); + p2 = spRotate(p2, dd * SP_PI * 5.0f); + + float d0 = star5(p2, 0.45f, 1.6f, 0.2f) - 0.02f; + float d1 = d0 - 0.01f; + float d2 = length(p2); + float colp = SP_PI * 100.0f; + float colaa = aa * 200.0f; + + float stripe = mix( + 0.5f / max(d2 * d2, 1.0e-4f), + 1.0f, + smoothstep(-0.5f + colaa, 0.5f + colaa, sin(d2 * colp))); + float3 col = palette(0.5f * planeIndex + 2.0f * d2) * stripe / max(3.0f * d2 * d2, 1.0e-1f); + col = mix(col, float3(2.0f), smoothstep(aa, -aa, d1)); + float alpha = smoothstep(aa, -aa, -d0); + + float starField = exp(-28.0f * abs(d0)) + 0.45f / (1.0f + 60.0f * d2 * d2); + col += float3(1.6f, 1.7f, 2.2f) * starField * 0.12f; + return float4(col, alpha); +} + +static float3 colorAlongPath(float3 ww, float3 uu, float3 vv, float3 ro, float2 p, float time) { + float2 np = p + float2(0.0015f, 0.0015f); + float rdd = 1.75f; + + float3 rd = normalize(p.x * uu + p.y * vv + rdd * ww); + float3 nrd = normalize(np.x * uu + np.y * vv + rdd * ww); + if (abs(rd.z) < 1.0e-4f || abs(nrd.z) < 1.0e-4f || abs(ww.z) < 1.0e-4f) { + return float3(0.0f); + } + + float nz = floor(ro.z / SP_PLANE_DIST); + float4 accum = float4(0.0f); + float3 advancingOrigin = ro; + float apd = 0.0f; + + for (int stepIndex = 1; stepIndex <= 16; ++stepIndex) { + if (accum.w > 0.95f) { + break; + } + + float planeIndex = nz + float(stepIndex); + float pz = SP_PLANE_DIST * planeIndex; + float lpd = (pz - advancingOrigin.z) / rd.z; + float npd = (pz - advancingOrigin.z) / nrd.z; + float cpd = (pz - advancingOrigin.z) / ww.z; + if (lpd <= 0.0f || npd <= 0.0f || cpd <= 0.0f) { + continue; + } + + float3 pp = advancingOrigin + rd * lpd; + float3 npp = advancingOrigin + nrd * npd; + float3 cp = advancingOrigin + ww * cpd; + + apd += lpd; + + float dz = pp.z - ro.z; + float fadeIn = smoothstep(SP_PLANE_DIST * SP_FURTHEST, SP_PLANE_DIST * SP_FADE_FROM, dz); + float fadeOut = smoothstep(0.0f, SP_PLANE_DIST * 0.1f, dz); + float fadeOutRI = smoothstep(0.0f, SP_PLANE_DIST * 1.0f, dz); + float ri = mix(1.0f, 0.9f, fadeOutRI * fadeIn); + + float4 pcol = planeColor(ro, rd, pp, npp, apd, planeIndex); + pcol.xyz *= mix(0.92f, 1.12f, sin(cp.z * 0.35f + time * 0.4f) * 0.5f + 0.5f); + pcol.xyz *= ri; + pcol.w *= fadeOut * fadeIn; + accum = alphaBlend(accum, pcol); + advancingOrigin = pp; + } + + return accum.xyz * accum.w; +} + +vertex StarryPlanesVertexOut starryPlanesVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant StarryPlanesUniforms &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; + + StarryPlanesVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +fragment float4 starryPlanesFragment( + StarryPlanesVertexOut in [[stage_in]], + constant StarryPlanesUniforms &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 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); + + bool insideOuter = all(abs(eye) < SP_BOX_HALF - 1.0e-3f); + float2 tOuter = spBoxIntersect(eye, viewDir, SP_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 localOrigin = eye + viewDir * (tStart + 0.001f); + + float time = uniforms.time * 0.9f; + float pathTime = SP_PLANE_DIST * time * 1.7f; + float3 pathOrigin = offsetCurve(pathTime); + float3 tangent = normalize(dOffsetCurve(pathTime)); + float3 curvature = ddOffsetCurve(pathTime); + float3 binormal = normalize(cross(float3(0.0f, 1.0f, 0.0f) + curvature, tangent)); + if (!all(isfinite(binormal)) || length(binormal) < 1.0e-4f) { + binormal = normalize(cross(float3(1.0f, 0.0f, 0.0f), tangent)); + } + float3 normal = cross(tangent, binormal); + + float3 sceneOrigin = localOrigin * 2.4f; + sceneOrigin.xy += pathOrigin.xy; + sceneOrigin.z += pathTime; + + float3 ww = tangent; + float3 uu = binormal; + float3 vv = normal; + float2 p = float2(dot(viewDir, uu), dot(viewDir, vv)) / max(dot(viewDir, ww), 0.25f); + + float3 col = colorAlongPath(ww, uu, vv, sceneOrigin, p, time); + + float trail = pow(clamp(1.0f - abs(dot(viewDir, ww)), 0.0f, 1.0f), 2.0f); + float haze = 0.06f / (0.12f + abs(viewDir.y)); + float3 background = float3(0.01f, 0.015f, 0.03f); + background += palette(pathTime * 0.35f + trail) * (0.04f + 0.08f * trail); + background += float3(0.6f, 0.7f, 1.0f) * haze * 0.08f; + + float2 faceUV = spFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + + col += background; + col = acesApprox(col); + col = sqrt(max(col, 0.0f)); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/StarryPlanes/StarryPlanesTypes.swift b/vr-dive/Demos/StarryPlanes/StarryPlanesTypes.swift new file mode 100644 index 0000000..ed71df6 --- /dev/null +++ b/vr-dive/Demos/StarryPlanes/StarryPlanesTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct StarryPlanesUniforms in StarryPlanesShaders.metal. +struct StarryPlanesUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/SteampunkOrb/SteampunkOrbRenderer.swift b/vr-dive/Demos/SteampunkOrb/SteampunkOrbRenderer.swift new file mode 100644 index 0000000..d5bc68b --- /dev/null +++ b/vr-dive/Demos/SteampunkOrb/SteampunkOrbRenderer.swift @@ -0,0 +1,176 @@ +import Metal +import simd + +// SteampunkOrbRenderer.swift +// 3D cube-container adaptation of "Steampunk Orb" (ShaderToy WXfcWN) by Jaenam. +// © 2025 Jaenam — CC BY-NC-SA 4.0 + +final class SteampunkOrbRenderer: VisualPatternController { + let identifier: VisualPatternKind = .steampunkOrb + 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 × scale 1.0 m → cubeScale = 1.0 + 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 = SteampunkOrbRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0)) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SteampunkOrbRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = SteampunkOrbRenderer.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 = SteampunkOrbUniforms( + 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 SteampunkOrbRenderer { + 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: "steampunkOrbVertex") + desc.fragmentFunction = library.makeFunction(name: "steampunkOrbFragment") + 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/SteampunkOrb/SteampunkOrbShaders.metal b/vr-dive/Demos/SteampunkOrb/SteampunkOrbShaders.metal new file mode 100644 index 0000000..296b749 --- /dev/null +++ b/vr-dive/Demos/SteampunkOrb/SteampunkOrbShaders.metal @@ -0,0 +1,200 @@ +// SteampunkOrbShaders.metal +// 3D visionOS adaptation of "Steampunk Orb" by Jaenam (ShaderToy WXfcWN). +// +// Original GLSL source: +// https://www.shadertoy.com/view/WXfcWN +// © 2025 Jaenam — CC BY-NC-SA 4.0 +// https://x.com/Jaenam97/status/1974927996898390144 +// +// Ported to Metal / visionOS cube-container ray march by the vr-dive project. +// Rendering strategy: rasterise the 6 faces of a world-space cube; for each +// fragment reconstruct the ray from the camera through the cube surface and +// march inward. Inside-camera support is handled by setting tStart = 0. + +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Shared types (must match SteampunkOrbTypes.swift) +// --------------------------------------------------------------------------- + +struct SteampunkOrbUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SteampunkOrbVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Vertex +// --------------------------------------------------------------------------- + +vertex SteampunkOrbVertexOut steampunkOrbVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SteampunkOrbUniforms &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; + + SteampunkOrbVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Rotation matrix for 2-component plane — equivalent to GLSL #define R(a) +static float2x2 soRotate(float a) { + float c = cos(a), s = sin(a); + // Metal: column-major. col0=(c,s), col1=(-s,c) → same as GLSL mat2(c,-s,s,c) + return float2x2(float2(c, s), float2(-s, c)); +} + +// Axis-aligned box intersection. Returns (tNear, tFar). +static float2 soBoxIntersect(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)); +} + +// --------------------------------------------------------------------------- +// Core raymarch — translated directly from the GLSL original. +// +// The original iterates 120 steps in a for-loop, accumulates colour via +// O += 25*d/s +// and finishes with tanh tone-mapping. +// +// Key translation notes (GLSL → Metal): +// • p.yz *= R(t*.1) → p.yz = soRotate(t*.1) * p.yz +// • mat2 mul order: GLSL col-vec "p *= M" == Metal "p = M * p" +// (Metal matrix × vector: result_i = row_i · vector, same convention) +// • float d,i,s,w,l are all zero-initialised in GLSL — explicit here. +// • O*=i at loop start with i=0 clears O to zero (already zero-init here). +// • "for(int i; i++ < 5; ...)" inner loop starts i=0, body runs while i<5. +// --------------------------------------------------------------------------- + +static float3 steampunkOrbTrace(float3 ro, float3 rd, float t) { + float4 O = float4(0.0f); + float d = 0.0f; + + // Bounding sphere radius for the orb — used as a gate to skip the + // expensive fold when the ray is clearly outside the structure. + const float GATE_R = 0.32f; + const float GATE_MARGIN = 0.01f; + + for (float i = 0.0f; i < 80.0f; i += 1.0f) { + // q = original ray position; p = rotated copy for folding + float3 q = ro + rd * d; + + // Fast gate: if outside bounding sphere, step by sphere distance + // without evaluating the fold — large step, no colour accumulation. + float gateDist = length(q) - GATE_R; + if (gateDist > GATE_MARGIN) { + d += gateDist * 0.9f; + continue; + } + + float3 p = q; + + // Apply two time-driven rotations (same as original p.yz*=R, p.xz*=R) + p.yz = soRotate(t * 0.1f) * p.yz; + p.xz = soRotate(t * 0.1f) * p.xz; + + // Apollonian fold — 5 iterations + float w = 8.0f; + float l = 1.0f; + for (int j = 0; j < 5; j++) { + p = sin(p); + l = 1.8f / dot(p, p); + p *= l; + w *= l; + } + + // SDF: outer sphere ∪ folded length + float s = max(length(q) - 0.3f, length(p.xz) / w); + + d += s; + O += 25.0f * d / s; + } + + // tanh tone-map: O channels mapped to (1,2,3) tint, scalar denom 2e7 + float3 col = tanh(float3(1.0f, 2.0f, 3.0f) * O.xyz / 2.0e7f); + return col; +} + +// --------------------------------------------------------------------------- +// Fragment +// --------------------------------------------------------------------------- + +fragment float4 steampunkOrbFragment( + SteampunkOrbVertexOut in [[stage_in]], + constant SteampunkOrbUniforms &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 in local cube space + float3 cameraWorld = float3(v2w[3].x, v2w[3].y, v2w[3].z); + float3 eye = (cameraWorld - center) / scale; + + // Surface point in local cube space + float3 surfacePos = (in.worldPos - center) / scale; + float3 viewDir = normalize(surfacePos - eye); + + // Box intersection to find ray segment + bool insideBox = all(abs(eye) < halfExt - 1.0e-3f); + float2 tBox = soBoxIntersect(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(); + } + + // Ray origin at cube entry (+ small offset to avoid self-intersection) + float3 ro = eye + viewDir * (tStart + 0.001f); + + // Scale scene: with cubeScale=4 the cube spans ±4 m in world space. + // sceneScale=0.45 maps the cube local ±1 → ±0.45 scene units, placing + // the 0.3-radius orb near the centre and filling most of the cube volume. + const float sceneScale = 0.45f; + float3 roScene = ro * sceneScale; + + float3 col = steampunkOrbTrace(roScene, viewDir, uniforms.time); + + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} diff --git a/vr-dive/Demos/SteampunkOrb/SteampunkOrbTypes.swift b/vr-dive/Demos/SteampunkOrb/SteampunkOrbTypes.swift new file mode 100644 index 0000000..49c16c2 --- /dev/null +++ b/vr-dive/Demos/SteampunkOrb/SteampunkOrbTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct SteampunkOrbUniforms in +/// SteampunkOrbShaders.metal. +struct SteampunkOrbUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Stereographic/StereographicRenderer.swift b/vr-dive/Demos/Stereographic/StereographicRenderer.swift new file mode 100644 index 0000000..3bade34 --- /dev/null +++ b/vr-dive/Demos/Stereographic/StereographicRenderer.swift @@ -0,0 +1,1244 @@ +import Metal +import simd + +enum RegularPolychoronKind { + case fiveCell + case eightCell + case sixteenCell + case twentyFourCell + case oneHundredTwentyCell + case sixHundredCell +} + +final class StereographicRenderer: VisualPatternController { + private struct PolychoronEdge { + let start: Int + let end: Int + } + + private struct PolychoronCell { + let vertices: [Int] + let edgeIndices: [Int] + let direction4D: SIMD4 + } + + private struct EdgeKey: Hashable { + let start: Int + let end: Int + + init(_ first: Int, _ second: Int) { + self.start = min(first, second) + self.end = max(first, second) + } + } + + private struct PolychoronDefinition { + let kind: RegularPolychoronKind + let vertices4D: [SIMD4] + let edges: [PolychoronEdge] + let edgeColors: [SIMD4] + let cells: [PolychoronCell] + } + + private struct PolychoronStyle { + let edgeSegments: Int + let radialSegments: Int + let sphereLatitudeSegments: Int + let sphereLongitudeSegments: Int + let meshUpdateInterval: Float + let worldScale: Float + let worldOffset: SIMD3 + let baseRadius: Float + let minimumRadius: Float + let maximumRadius: Float + let junctionRadiusScale: Float + let junctionColor: SIMD4 + } + + private struct MeshLayout { + let edgeStride: Int + let sphereStride: Int + let totalVertexCount: Int + } + + let identifier: VisualPatternKind + let preferredClearColor = MTLClearColor(red: 0.01, green: 0.01, blue: 0.015, alpha: 1) + + private static let maxDeltaTime: Float = 1.0 / 45.0 + private static let primaryRotationSpeed: Float = 0.18 + private static let secondaryRotationSpeed: Float = 0.09 + private static let baseOrientationAngles = SIMD3(-0.8, -0.66, 0.96) + private static let junctionHighlight = SIMD4(0.93, 0.94, 0.97, 1.0) + private static let e1 = SIMD4(1.0 / sqrt(2.0), -1.0 / sqrt(2.0), 0.0, 0.0) + private static let e2 = SIMD4(0.5, 0.5, -0.5, -0.5) + private static let e3 = SIMD4(0.0, 0.0, 1.0 / sqrt(2.0), -1.0 / sqrt(2.0)) + private static let nPole = SIMD4(repeating: 0.5) + private static let inspectionHighlightColor = SIMD4(1.0, 0.95, 0.48, 1.0) + private static let inspectionDimFactor: Float = 0.14 + private static let inspectionEdgeRadiusMultiplier: Float = 1.9 + private static let inspectionJunctionRadiusMultiplier: Float = 1.18 + private static let edgePalette: [SIMD4] = [ + SIMD4(0.90, 0.28, 0.34, 1.0), + SIMD4(0.96, 0.66, 0.22, 1.0), + SIMD4(0.95, 0.90, 0.27, 1.0), + SIMD4(0.23, 0.78, 0.50, 1.0), + SIMD4(0.27, 0.62, 0.96, 1.0), + SIMD4(0.72, 0.42, 0.96, 1.0), + ] + private static let definitionsByKind = buildPolychoronDefinitions() + private static let polychoronScaleMultiplier: Float = 2.0 + + private let pipelineState: MTLRenderPipelineState + private let depthStencilState: MTLDepthStencilState + private let vertexBuffer: MTLBuffer + private let indexBuffer: MTLBuffer + private let indexCount: Int + private let definition: PolychoronDefinition + private let style: PolychoronStyle + private let meshLayout: MeshLayout + private var animationTime: Float = 0 + private var lastSimulationTimestamp: Float? + private var meshUpdateAccumulator: Float = 0 + private var originCellInspectionEnabled = false + + init( + device: MTLDevice, + library: MTLLibrary, + patternKind: VisualPatternKind, + polychoronKind: RegularPolychoronKind, + maxViewCount: Int + ) throws { + guard let definition = Self.definitionsByKind[polychoronKind] else { + preconditionFailure("Missing polychoron definition for \(polychoronKind)") + } + + self.identifier = patternKind + self.definition = definition + self.style = Self.style(for: polychoronKind) + self.meshLayout = Self.meshLayout(for: definition, style: style) + + pipelineState = try Self.makePipelineState( + device: device, + library: library, + maxViewCount: max(1, maxViewCount) + ) + + let depthDescriptor = MTLDepthStencilDescriptor() + depthDescriptor.depthCompareFunction = .greater + depthDescriptor.isDepthWriteEnabled = true + depthStencilState = device.makeDepthStencilState(descriptor: depthDescriptor)! + + let initialVertices = Self.generateMeshVertices( + definition: definition, + style: style, + meshLayout: meshLayout, + animationTime: 0, + originCellInspectionEnabled: false + ) + vertexBuffer = device.makeBuffer( + bytes: initialVertices, + length: MemoryLayout.stride * initialVertices.count, + options: [.storageModeShared] + )! + + let indices = Self.generateMeshIndices( + definition: definition, style: style, meshLayout: meshLayout) + indexCount = indices.count + indexBuffer = device.makeBuffer( + bytes: indices, + length: MemoryLayout.stride * indices.count, + options: [.storageModeShared] + )! + } + + func synchronizeState(_ context: PatternSimulationContext) { + let inspectionEnabled = context.originCellInspectionEnabled && !definition.cells.isEmpty + guard originCellInspectionEnabled != inspectionEnabled else { return } + originCellInspectionEnabled = inspectionEnabled + rebuildMesh(animationTime: animationTime) + } + + func updateSimulation(_ context: PatternSimulationContext) { + let deltaTime: Float + if let lastSimulationTimestamp { + deltaTime = min(max(context.time - lastSimulationTimestamp, 0), Self.maxDeltaTime) + } else { + deltaTime = 0 + } + lastSimulationTimestamp = context.time + animationTime += deltaTime * max(0, context.speedMultiplier) + + let updateInterval = max(0, style.meshUpdateInterval) + if updateInterval > 0 { + meshUpdateAccumulator += deltaTime + guard meshUpdateAccumulator >= updateInterval else { return } + meshUpdateAccumulator.formTruncatingRemainder(dividingBy: updateInterval) + } + + rebuildMesh(animationTime: animationTime) + } + + func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) { + encoder.setRenderPipelineState(pipelineState) + encoder.setDepthStencilState(depthStencilState) + encoder.setCullMode(.none) + encoder.setFrontFacing(.counterClockwise) + + context.applyViewConfiguration(on: encoder) + + encoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0) + + var uniforms = SceneUniforms(time: context.time, layerCount: UInt32(context.viewData.viewCount)) + encoder.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 2) + + var matrices = context.viewData.viewProjectionMatrices + if matrices.isEmpty { + matrices = [matrix_identity_float4x4] + } + matrices.withUnsafeBytes { ptr in + if let base = ptr.baseAddress, ptr.count > 0 { + encoder.setVertexBytes(base, length: ptr.count, index: 3) + } + } + + encoder.drawIndexedPrimitives( + type: .triangle, + indexCount: indexCount, + indexType: .uint32, + indexBuffer: indexBuffer, + indexBufferOffset: 0 + ) + } + + func resetToInitialState() { + animationTime = 0 + lastSimulationTimestamp = nil + meshUpdateAccumulator = 0 + let vertices = Self.generateMeshVertices( + definition: definition, + style: style, + meshLayout: meshLayout, + animationTime: 0, + originCellInspectionEnabled: originCellInspectionEnabled + ) + vertices.withUnsafeBytes { ptr in + guard let base = ptr.baseAddress, ptr.count > 0 else { return } + memcpy(vertexBuffer.contents(), base, ptr.count) + } + } + + private func rebuildMesh(animationTime: Float) { + let vertices = Self.generateMeshVertices( + definition: definition, + style: style, + meshLayout: meshLayout, + animationTime: animationTime, + originCellInspectionEnabled: originCellInspectionEnabled + ) + vertices.withUnsafeBytes { ptr in + guard let base = ptr.baseAddress, ptr.count > 0 else { return } + memcpy(vertexBuffer.contents(), base, ptr.count) + } + } + + private static func makePipelineState( + device: MTLDevice, + library: MTLLibrary, + maxViewCount: Int + ) throws -> MTLRenderPipelineState { + let descriptor = MTLRenderPipelineDescriptor() + descriptor.vertexFunction = library.makeFunction(name: "stereographicVertexShader") + descriptor.fragmentFunction = library.makeFunction(name: "stereographicFragmentShader") + descriptor.colorAttachments[0].pixelFormat = .rgba16Float + descriptor.depthAttachmentPixelFormat = .depth32Float + descriptor.inputPrimitiveTopology = .triangle + + 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.attributes[2].format = .float4 + vertexDescriptor.attributes[2].offset = MemoryLayout>.stride * 2 + vertexDescriptor.attributes[2].bufferIndex = 0 + vertexDescriptor.layouts[0].stride = MemoryLayout.stride + descriptor.vertexDescriptor = vertexDescriptor + descriptor.maxVertexAmplificationCount = maxViewCount + + return try device.makeRenderPipelineState(descriptor: descriptor) + } + + private static func generateMeshVertices( + definition: PolychoronDefinition, + style: PolychoronStyle, + meshLayout: MeshLayout, + animationTime: Float, + originCellInspectionEnabled: Bool + ) -> [StereographicVertex] { + var vertices: [StereographicVertex] = [] + vertices.reserveCapacity(meshLayout.totalVertexCount) + + let highlightedCellIndex = highlightedCellIndex( + for: definition, + animationTime: animationTime, + inspectionEnabled: originCellInspectionEnabled + ) + let highlightedEdges = highlightedCellIndex.map { Set(definition.cells[$0].edgeIndices) } ?? [] + let highlightedVertices = highlightedCellIndex.map { Set(definition.cells[$0].vertices) } ?? [] + let inspectionEnabled = highlightedCellIndex != nil + + for (edgeIndex, edge) in definition.edges.enumerated() { + let start4D = rotate4D(point: definition.vertices4D[edge.start], time: animationTime) + let end4D = rotate4D(point: definition.vertices4D[edge.end], time: animationTime) + let isHighlighted = highlightedEdges.contains(edgeIndex) + + var centers = Array(repeating: SIMD3(repeating: 0), count: style.edgeSegments + 1) + var tangents = Array(repeating: SIMD3(repeating: 0), count: style.edgeSegments + 1) + var radii = Array(repeating: Float(0), count: style.edgeSegments + 1) + + for segmentIndex in 0...style.edgeSegments { + let interpolation = Float(segmentIndex) / Float(style.edgeSegments) + let point = sphericalInterpolate(from: start4D, to: end4D, t: interpolation) + let projected = projectedPoint(for: point, style: style) + centers[segmentIndex] = projected.position + radii[segmentIndex] = + projected.radius * (isHighlighted ? inspectionEdgeRadiusMultiplier : 1.0) + } + + for segmentIndex in 0...style.edgeSegments { + let previous = centers[max(0, segmentIndex - 1)] + let next = centers[min(style.edgeSegments, segmentIndex + 1)] + let delta = next - previous + tangents[segmentIndex] = + simd_length_squared(delta) > 1e-8 ? simd_normalize(delta) : SIMD3(1, 0, 0) + } + + let frames = makeTransportFrames(centers: centers, tangents: tangents) + let color: SIMD4 + if inspectionEnabled { + color = + isHighlighted + ? inspectionHighlightColor + : dimmedColor(definition.edgeColors[edgeIndex], factor: inspectionDimFactor) + } else { + color = definition.edgeColors[edgeIndex] + } + + for segmentIndex in 0...style.edgeSegments { + let frame = frames[segmentIndex] + for radialIndex in 0.. + if inspectionEnabled { + color = + isHighlighted + ? inspectionHighlightColor + : dimmedColor(style.junctionColor, factor: inspectionDimFactor) + } else { + color = style.junctionColor + } + + for latitude in 0...style.sphereLatitudeSegments { + let v = Float(latitude) / Float(style.sphereLatitudeSegments) + let phi = v * .pi + let sinPhi = sin(phi) + let cosPhi = cos(phi) + + for longitude in 0...style.sphereLongitudeSegments { + let u = Float(longitude) / Float(style.sphereLongitudeSegments) + let theta = u * (2.0 * .pi) + let sinTheta = sin(theta) + let cosTheta = cos(theta) + + let normal = SIMD3(sinPhi * cosTheta, cosPhi, sinPhi * sinTheta) + let position = projected.position + normal * radius + vertices.append( + StereographicVertex(position: position, normal: normal, color: color) + ) + } + } + } + + return vertices + } + + private static func generateMeshIndices( + definition: PolychoronDefinition, + style: PolychoronStyle, + meshLayout: MeshLayout + ) -> [UInt32] { + var indices: [UInt32] = [] + let edgeIndexCount = definition.edges.count * style.edgeSegments * style.radialSegments * 6 + let sphereIndexCount = + definition.vertices4D.count * style.sphereLatitudeSegments * style.sphereLongitudeSegments * 6 + indices.reserveCapacity(edgeIndexCount + sphereIndexCount) + + for edgeIndex in definition.edges.indices { + let base = edgeIndex * meshLayout.edgeStride + for segmentIndex in 0.. MeshLayout + { + let edgeStride = (style.edgeSegments + 1) * style.radialSegments + let sphereStride = (style.sphereLatitudeSegments + 1) * (style.sphereLongitudeSegments + 1) + let totalVertexCount = + definition.edges.count * edgeStride + definition.vertices4D.count * sphereStride + return MeshLayout( + edgeStride: edgeStride, sphereStride: sphereStride, totalVertexCount: totalVertexCount) + } + + private static func style(for kind: RegularPolychoronKind) -> PolychoronStyle { + switch kind { + case .fiveCell: + return PolychoronStyle( + edgeSegments: 24, + radialSegments: 10, + sphereLatitudeSegments: 5, + sphereLongitudeSegments: 8, + meshUpdateInterval: 0, + worldScale: 0.29, + worldOffset: SIMD3(0, 0, -1.2), + baseRadius: 0.017, + minimumRadius: 0.0028, + maximumRadius: 0.022, + junctionRadiusScale: 1.12, + junctionColor: junctionHighlight + ) + case .eightCell: + return PolychoronStyle( + edgeSegments: 18, + radialSegments: 10, + sphereLatitudeSegments: 5, + sphereLongitudeSegments: 8, + meshUpdateInterval: 0, + worldScale: 0.24, + worldOffset: SIMD3(0, 0, -1.2), + baseRadius: 0.013, + minimumRadius: 0.0022, + maximumRadius: 0.016, + junctionRadiusScale: 1.08, + junctionColor: junctionHighlight + ) + case .sixteenCell: + return PolychoronStyle( + edgeSegments: 36, + radialSegments: 12, + sphereLatitudeSegments: 6, + sphereLongitudeSegments: 10, + meshUpdateInterval: 0, + worldScale: 0.26, + worldOffset: SIMD3(0, 0, -1.2), + baseRadius: 0.018, + minimumRadius: 0.003, + maximumRadius: 0.024, + junctionRadiusScale: 1.15, + junctionColor: junctionHighlight + ) + case .twentyFourCell: + return PolychoronStyle( + edgeSegments: 32, + radialSegments: 12, + sphereLatitudeSegments: 6, + sphereLongitudeSegments: 10, + meshUpdateInterval: 0, + worldScale: 0.24, + worldOffset: SIMD3(0, 0, -1.2), + baseRadius: 0.012, + minimumRadius: 0.0022, + maximumRadius: 0.016, + junctionRadiusScale: 1.12, + junctionColor: junctionHighlight + ) + case .oneHundredTwentyCell: + return PolychoronStyle( + edgeSegments: 7, + radialSegments: 4, + sphereLatitudeSegments: 2, + sphereLongitudeSegments: 3, + meshUpdateInterval: 1.0 / 20.0, + worldScale: 0.15, + worldOffset: SIMD3(0, 0, -1.25), + baseRadius: 0.0048, + minimumRadius: 0.00075, + maximumRadius: 0.0048, + junctionRadiusScale: 0.9, + junctionColor: junctionHighlight + ) + case .sixHundredCell: + return PolychoronStyle( + edgeSegments: 6, + radialSegments: 4, + sphereLatitudeSegments: 2, + sphereLongitudeSegments: 3, + meshUpdateInterval: 1.0 / 15.0, + worldScale: 0.18, + worldOffset: SIMD3(0, 0, -1.25), + baseRadius: 0.0042, + minimumRadius: 0.0007, + maximumRadius: 0.0055, + junctionRadiusScale: 0.88, + junctionColor: junctionHighlight + ) + } + } + + private static func buildPolychoronDefinitions() -> [RegularPolychoronKind: PolychoronDefinition] + { + let fiveCellVertices = buildFiveCellVertices() + let eightCellVertices = buildEightCellVertices() + let sixteenCellVertices = buildSixteenCellVertices() + let twentyFourCellVertices = buildTwentyFourCellVertices() + let sixHundredCellVertices = buildSixHundredCellVertices() + let fiveCellCells = buildFiveCellCells(vertexCount: fiveCellVertices.count) + let eightCellCells = buildEightCellCells(vertices: eightCellVertices) + let sixteenCellCells = buildSixteenCellCells(vertices: sixteenCellVertices) + + let fiveCellEdges = buildEdgePairs( + vertices: fiveCellVertices, + expectedValence: 4, + expectedEdgeCount: 10 + ) + let eightCellEdges = buildEdgePairs( + vertices: eightCellVertices, + expectedValence: 4, + expectedEdgeCount: 32 + ) + let sixteenCellEdges = buildEdgePairs( + vertices: sixteenCellVertices, + expectedValence: 6, + expectedEdgeCount: 24 + ) + let twentyFourCellEdges = buildEdgePairs( + vertices: twentyFourCellVertices, + expectedValence: 8, + expectedEdgeCount: 96 + ) + let twentyFourCellCells = buildTwentyFourCellCells( + vertexCount: twentyFourCellVertices.count, + edges: twentyFourCellEdges + ) + let sixHundredCellEdges = buildEdgePairs( + vertices: sixHundredCellVertices, + expectedValence: 12, + expectedEdgeCount: 720 + ) + let sixHundredCellTetrahedra = buildTetrahedralCells( + vertexCount: sixHundredCellVertices.count, + edges: sixHundredCellEdges + ) + precondition(sixHundredCellTetrahedra.count == 600, "Expected 600 tetrahedra for the 600-cell") + + let oneHundredTwentyCellVertices = buildDualVertices( + cells: sixHundredCellTetrahedra, + sourceVertices: sixHundredCellVertices, + expectedVertexCount: 600 + ) + let oneHundredTwentyCellEdges = buildEdgePairs( + vertices: oneHundredTwentyCellVertices, + expectedValence: 4, + expectedEdgeCount: 1200 + ) + let oneHundredTwentyCellCells = buildOneHundredTwentyCellCells( + sourceVertexCount: sixHundredCellVertices.count, + sourceCells: sixHundredCellTetrahedra + ) + + return [ + .fiveCell: makeDefinition( + kind: .fiveCell, + vertices: fiveCellVertices, + edges: fiveCellEdges, + cellVertexSets: fiveCellCells + ), + .eightCell: makeDefinition( + kind: .eightCell, + vertices: eightCellVertices, + edges: eightCellEdges, + cellVertexSets: eightCellCells + ), + .sixteenCell: makeDefinition( + kind: .sixteenCell, + vertices: sixteenCellVertices, + edges: sixteenCellEdges, + cellVertexSets: sixteenCellCells + ), + .twentyFourCell: makeDefinition( + kind: .twentyFourCell, + vertices: twentyFourCellVertices, + edges: twentyFourCellEdges, + cellVertexSets: twentyFourCellCells + ), + .oneHundredTwentyCell: makeDefinition( + kind: .oneHundredTwentyCell, + vertices: oneHundredTwentyCellVertices, + edges: oneHundredTwentyCellEdges, + cellVertexSets: oneHundredTwentyCellCells, + cellDirections: sixHundredCellVertices.map(floatVector) + ), + .sixHundredCell: makeDefinition( + kind: .sixHundredCell, + vertices: sixHundredCellVertices, + edges: sixHundredCellEdges, + cellVertexSets: sixHundredCellTetrahedra + ), + ] + } + + private static func makeDefinition( + kind: RegularPolychoronKind, + vertices: [SIMD4], + edges: [PolychoronEdge], + cellVertexSets: [[Int]] = [], + cellDirections: [SIMD4]? = nil + ) -> PolychoronDefinition { + let floatVertices = vertices.map(floatVector) + let edgeColors = edges.enumerated().map { edgePalette[$0.offset % edgePalette.count] } + let cells = buildCells( + vertexSets: cellVertexSets, + vertices: floatVertices, + edges: edges, + directions: cellDirections + ) + return PolychoronDefinition( + kind: kind, + vertices4D: floatVertices, + edges: edges, + edgeColors: edgeColors, + cells: cells + ) + } + + private static func buildCells( + vertexSets: [[Int]], + vertices: [SIMD4], + edges: [PolychoronEdge], + directions: [SIMD4]? + ) -> [PolychoronCell] { + guard !vertexSets.isEmpty else { return [] } + if let directions { + precondition(directions.count == vertexSets.count, "Cell direction count mismatch") + } + + var edgeIndicesByKey: [EdgeKey: Int] = [:] + for (edgeIndex, edge) in edges.enumerated() { + edgeIndicesByKey[EdgeKey(edge.start, edge.end)] = edgeIndex + } + + return vertexSets.enumerated().map { cellIndex, vertexSet in + var edgeIndices: [Int] = [] + for i in 0.. + if let directions { + direction = simd_normalize(directions[cellIndex]) + } else { + var accumulatedDirection = SIMD4(repeating: 0) + for vertexIndex in vertexSet { + accumulatedDirection += vertices[vertexIndex] + } + direction = simd_normalize(accumulatedDirection) + } + + return PolychoronCell( + vertices: vertexSet, + edgeIndices: edgeIndices, + direction4D: direction + ) + } + } + + private static func buildOneHundredTwentyCellCells( + sourceVertexCount: Int, + sourceCells: [[Int]] + ) -> [[Int]] { + var cells = Array(repeating: [Int](), count: sourceVertexCount) + for (cellIndex, sourceCell) in sourceCells.enumerated() { + for sourceVertex in sourceCell { + cells[sourceVertex].append(cellIndex) + } + } + + precondition(cells.count == 120, "Expected 120 dodecahedral cells for the 120-cell") + precondition( + cells.allSatisfy { $0.count == 20 }, "Expected each 120-cell cell to have 20 vertices") + return cells + } + + private static func buildFiveCellCells(vertexCount: Int) -> [[Int]] { + let cells = (0..]) -> [[Int]] { + let epsilon = 1e-10 + var cells: [[Int]] = [] + cells.reserveCapacity(8) + + for axis in 0..<4 { + for fixedValue in [-0.5, 0.5] { + let cell = vertices.enumerated().compactMap { index, vertex in + abs(vertex[axis] - fixedValue) < epsilon ? index : nil + } + precondition(cell.count == 8, "Expected 8 vertices in each 8-cell cube") + cells.append(cell) + } + } + + precondition(cells.count == 8, "Expected 8 cells for the 8-cell") + return cells + } + + private static func buildSixteenCellCells(vertices: [SIMD4]) -> [[Int]] { + var indicesByAxisAndSign: [Int: Int] = [:] + for (index, vertex) in vertices.enumerated() { + guard let axis = (0..<4).first(where: { abs(vertex[$0]) > 0.5 }) else { continue } + let signBit = vertex[axis] > 0 ? 1 : 0 + indicesByAxisAndSign[axis * 2 + signBit] = index + } + + var cells: [[Int]] = [] + cells.reserveCapacity(16) + for mask in 0..<(1 << 4) { + var cell: [Int] = [] + for axis in 0..<4 { + let signBit = (mask >> axis) & 1 + guard let vertexIndex = indicesByAxisAndSign[axis * 2 + signBit] else { + preconditionFailure("Missing 16-cell vertex for axis/sign combination") + } + cell.append(vertexIndex) + } + cells.append(cell) + } + + precondition(cells.count == 16, "Expected 16 cells for the 16-cell") + return cells + } + + private static func buildTwentyFourCellCells( + vertexCount: Int, + edges: [PolychoronEdge] + ) -> [[Int]] { + var adjacency = Array(repeating: Set(), count: vertexCount) + for edge in edges { + adjacency[edge.start].insert(edge.end) + adjacency[edge.end].insert(edge.start) + } + + var cells: [[Int]] = [] + cells.reserveCapacity(24) + + for a in 0..<(vertexCount - 5) { + for b in (a + 1)..<(vertexCount - 4) { + for c in (b + 1)..<(vertexCount - 3) { + for d in (c + 1)..<(vertexCount - 2) { + for e in (d + 1)..<(vertexCount - 1) { + for f in (e + 1).. [SIMD4] { + var vertices: [SIMD4] = [] + vertices.reserveCapacity(8) + + for axis in 0..<4 { + for sign in [-1.0, 1.0] { + var point = SIMD4(repeating: 0) + point[axis] = sign + vertices.append(point) + } + } + + return vertices + } + + private static func buildFiveCellVertices() -> [SIMD4] { + let rootFive = sqrt(5.0) + return [ + SIMD4(rootFive / 4.0, rootFive / 4.0, rootFive / 4.0, -0.25), + SIMD4(rootFive / 4.0, -rootFive / 4.0, -rootFive / 4.0, -0.25), + SIMD4(-rootFive / 4.0, rootFive / 4.0, -rootFive / 4.0, -0.25), + SIMD4(-rootFive / 4.0, -rootFive / 4.0, rootFive / 4.0, -0.25), + SIMD4(0, 0, 0, 1), + ] + } + + private static func buildEightCellVertices() -> [SIMD4] { + var vertices: [SIMD4] = [] + vertices.reserveCapacity(16) + + for signX in [-0.5, 0.5] { + for signY in [-0.5, 0.5] { + for signZ in [-0.5, 0.5] { + for signW in [-0.5, 0.5] { + vertices.append(SIMD4(signX, signY, signZ, signW)) + } + } + } + } + + return vertices + } + + private static func buildTwentyFourCellVertices() -> [SIMD4] { + let scale = 1.0 / sqrt(2.0) + var vertices: [SIMD4] = [] + vertices.reserveCapacity(24) + + for axisA in 0..<4 { + for axisB in (axisA + 1)..<4 { + for signA in [-1.0, 1.0] { + for signB in [-1.0, 1.0] { + var point = SIMD4(repeating: 0) + point[axisA] = signA * scale + point[axisB] = signB * scale + vertices.append(point) + } + } + } + } + + return vertices + } + + private static func buildSixHundredCellVertices() -> [SIMD4] { + let phi = (1.0 + sqrt(5.0)) * 0.5 + let inversePhi = 1.0 / phi + var vertices: [SIMD4] = [] + vertices.reserveCapacity(120) + + for axis in 0..<4 { + for sign in [-1.0, 1.0] { + var point = SIMD4(repeating: 0) + point[axis] = sign + vertices.append(point) + } + } + + for signX in [-0.5, 0.5] { + for signY in [-0.5, 0.5] { + for signZ in [-0.5, 0.5] { + for signW in [-0.5, 0.5] { + vertices.append(SIMD4(signX, signY, signZ, signW)) + } + } + } + } + + let base = SIMD4(0, 0.5, phi * 0.5, inversePhi * 0.5) + for permutation in coordinatePermutations(evenOnly: true) { + let permuted = permute(base, by: permutation) + let nonZeroAxes = (0..<4).filter { abs(permuted[$0]) > 1e-10 } + let signCombinations = 1 << nonZeroAxes.count + for signMask in 0..> bitIndex) & 1) == 0 ? -1.0 : 1.0 + point[axis] *= sign + } + appendUnique(point, to: &vertices) + } + } + + precondition(vertices.count == 120, "Expected 120 vertices for the 600-cell") + return vertices + } + + private static func buildEdgePairs( + vertices: [SIMD4], + expectedValence: Int, + expectedEdgeCount: Int + ) -> [PolychoronEdge] { + let threshold = adjacencyThreshold(vertices: vertices, expectedValence: expectedValence) + var edges: [PolychoronEdge] = [] + edges.reserveCapacity(expectedEdgeCount) + var degrees = Array(repeating: 0, count: vertices.count) + + for start in vertices.indices { + for end in (start + 1).. [[Int]] { + var adjacency = Array(repeating: Set(), count: vertexCount) + for edge in edges { + adjacency[edge.start].insert(edge.end) + adjacency[edge.end].insert(edge.start) + } + + var tetrahedra: [[Int]] = [] + tetrahedra.reserveCapacity(600) + + for a in 0.. a }.sorted() + let neighborSetA = Set(neighborsA) + + for b in neighborsA { + let commonAB = neighborSetA.intersection(adjacency[b].filter { $0 > b }) + for c in commonAB.sorted() { + let commonABC = commonAB.intersection(adjacency[c].filter { $0 > c }) + for d in commonABC.sorted() { + tetrahedra.append([a, b, c, d]) + } + } + } + } + + return tetrahedra + } + + private static func buildDualVertices( + cells: [[Int]], + sourceVertices: [SIMD4], + expectedVertexCount: Int + ) -> [SIMD4] { + var dualVertices: [SIMD4] = [] + dualVertices.reserveCapacity(expectedVertexCount) + + for cell in cells { + var sum = SIMD4(repeating: 0) + for index in cell { + sum += sourceVertices[index] + } + appendUnique(normalized(sum), to: &dualVertices) + } + + precondition(dualVertices.count == expectedVertexCount, "Unexpected dual vertex count") + return dualVertices + } + + private static func adjacencyThreshold(vertices: [SIMD4], expectedValence: Int) -> Double + { + var threshold = 0.0 + + for index in vertices.indices { + var distances: [Double] = [] + distances.reserveCapacity(vertices.count - 1) + for neighborIndex in vertices.indices where neighborIndex != index { + distances.append(squaredDistance(vertices[index], vertices[neighborIndex])) + } + distances.sort() + threshold = max(threshold, distances[expectedValence - 1]) + } + + return threshold + 1e-9 + } + + private static func rotate4D(point: SIMD4, time: Float) -> SIMD4 { + var rotated = point + rotated = rotateInPlane(point: rotated, i: 0, j: 3, angle: baseOrientationAngles.x) + rotated = rotateInPlane(point: rotated, i: 1, j: 2, angle: baseOrientationAngles.y) + rotated = rotateInPlane(point: rotated, i: 0, j: 1, angle: baseOrientationAngles.z) + rotated = rotateInPlane(point: rotated, i: 0, j: 1, angle: time * primaryRotationSpeed) + rotated = rotateInPlane(point: rotated, i: 2, j: 3, angle: time * secondaryRotationSpeed) + return rotated + } + + private static func inverseRotate4D(point: SIMD4, time: Float) -> SIMD4 { + var rotated = point + rotated = rotateInPlane(point: rotated, i: 2, j: 3, angle: -time * secondaryRotationSpeed) + rotated = rotateInPlane(point: rotated, i: 0, j: 1, angle: -time * primaryRotationSpeed) + rotated = rotateInPlane(point: rotated, i: 0, j: 1, angle: -baseOrientationAngles.z) + rotated = rotateInPlane(point: rotated, i: 1, j: 2, angle: -baseOrientationAngles.y) + rotated = rotateInPlane(point: rotated, i: 0, j: 3, angle: -baseOrientationAngles.x) + return rotated + } + + private static func highlightedCellIndex( + for definition: PolychoronDefinition, + animationTime: Float, + inspectionEnabled: Bool + ) -> Int? { + guard inspectionEnabled, !definition.cells.isEmpty else { return nil } + + let queryPoint = inverseRotate4D(point: -nPole, time: animationTime) + var bestIndex: Int? + var bestScore = -Float.greatestFiniteMagnitude + + for (cellIndex, cell) in definition.cells.enumerated() { + let score = simd_dot(queryPoint, cell.direction4D) + if score > bestScore { + bestScore = score + bestIndex = cellIndex + } + } + + return bestIndex + } + + private static func rotateInPlane(point: SIMD4, i: Int, j: Int, angle: Float) -> SIMD4< + Float + > { + var rotated = point + let cosine = cos(angle) + let sine = sin(angle) + let componentI = point[i] + let componentJ = point[j] + rotated[i] = cosine * componentI - sine * componentJ + rotated[j] = sine * componentI + cosine * componentJ + return rotated + } + + private static func sphericalInterpolate( + from start: SIMD4, + to end: SIMD4, + t: Float + ) -> SIMD4 { + let clampedDot = max(-1.0, min(1.0, simd_dot(start, end))) + if clampedDot > 0.9995 { + let linear = start + (end - start) * t + return simd_normalize(linear) + } + + let theta = acos(clampedDot) + let sinTheta = sin(theta) + let startWeight = sin((1.0 - t) * theta) / sinTheta + let endWeight = sin(t * theta) / sinTheta + return simd_normalize(start * startWeight + end * endWeight) + } + + private static func makeTransportFrames(centers: [SIMD3], tangents: [SIMD3]) + -> [(normal: SIMD3, binormal: SIMD3)] + { + guard !centers.isEmpty else { return [] } + + var frames = Array( + repeating: (normal: SIMD3(1, 0, 0), binormal: SIMD3(0, 1, 0)), + count: centers.count + ) + + let initialUp = + abs(simd_dot(tangents[0], SIMD3(0, 1, 0))) > 0.92 + ? SIMD3(1, 0, 0) + : SIMD3(0, 1, 0) + let initialNormal = simd_normalize(simd_cross(tangents[0], initialUp)) + let initialBinormal = simd_normalize(simd_cross(tangents[0], initialNormal)) + frames[0] = (initialNormal, initialBinormal) + + for index in 1.. + if axisLength > 1e-5 { + let unitAxis = axis / axisLength + let angle = atan2(axisLength, simd_dot(previousTangent, tangent)) + transportedNormal = rotate(vector: previousNormal, around: unitAxis, angle: angle) + } else { + transportedNormal = previousNormal + } + + let orthogonalNormal = simd_normalize( + transportedNormal - tangent * simd_dot(transportedNormal, tangent) + ) + let binormal = simd_normalize(simd_cross(tangent, orthogonalNormal)) + frames[index] = (orthogonalNormal, binormal) + } + + return frames + } + + private static func projectedPoint(for point: SIMD4, style: PolychoronStyle) + -> (position: SIMD3, radius: Float) + { + let dotWithPole = simd_dot(point, nPole) + let denominator = max(1e-4, 1.0 - dotWithPole) + let projection = (point - dotWithPole * nPole) / denominator + let worldScale = style.worldScale * polychoronScaleMultiplier + + let u = simd_dot(projection, e1) + let v = simd_dot(projection, e2) + let w = simd_dot(projection, e3) + let position = SIMD3(u, v, w) * worldScale + style.worldOffset + + let perspectiveRadius = style.baseRadius * worldScale / denominator + let radius = min(max(perspectiveRadius, style.minimumRadius), style.maximumRadius) + return (position, radius) + } + + private static func rotate(vector: SIMD3, around axis: SIMD3, angle: Float) + -> SIMD3 + { + let cosine = cos(angle) + let sine = sin(angle) + return vector * cosine + + simd_cross(axis, vector) * sine + + axis * simd_dot(axis, vector) * (1 - cosine) + } + + private static func dimmedColor(_ color: SIMD4, factor: Float) -> SIMD4 { + SIMD4(color.x * factor, color.y * factor, color.z * factor, color.w) + } + + private static func appendUnique(_ candidate: SIMD4, to vertices: inout [SIMD4]) { + if vertices.contains(where: { simd_length_squared($0 - candidate) < 1e-16 }) { + return + } + vertices.append(candidate) + } + + private static func normalized(_ vector: SIMD4) -> SIMD4 { + let length = sqrt( + vector.x * vector.x + vector.y * vector.y + vector.z * vector.z + vector.w * vector.w) + return vector / length + } + + private static func squaredDistance(_ left: SIMD4, _ right: SIMD4) -> Double { + let delta = left - right + return delta.x * delta.x + delta.y * delta.y + delta.z * delta.z + delta.w * delta.w + } + + private static func floatVector(_ vector: SIMD4) -> SIMD4 { + SIMD4(Float(vector.x), Float(vector.y), Float(vector.z), Float(vector.w)) + } + + private static func permute(_ vector: SIMD4, by permutation: [Int]) -> SIMD4 { + SIMD4( + vector[permutation[0]], + vector[permutation[1]], + vector[permutation[2]], + vector[permutation[3]] + ) + } + + private static func coordinatePermutations(evenOnly: Bool) -> [[Int]] { + var permutations: [[Int]] = [] + let values = [0, 1, 2, 3] + + func recurse(_ prefix: [Int], _ remaining: [Int]) { + if remaining.isEmpty { + if !evenOnly || inversionCount(of: prefix).isMultiple(of: 2) { + permutations.append(prefix) + } + return + } + + for index in remaining.indices { + var nextPrefix = prefix + nextPrefix.append(remaining[index]) + var nextRemaining = remaining + nextRemaining.remove(at: index) + recurse(nextPrefix, nextRemaining) + } + } + + recurse([], values) + return permutations + } + + private static func inversionCount(of values: [Int]) -> Int { + var inversions = 0 + for i in values.indices { + for j in (i + 1).. values[j] { + inversions += 1 + } + } + return inversions + } +} diff --git a/vr-dive/Demos/Stereographic/StereographicShaders.metal b/vr-dive/Demos/Stereographic/StereographicShaders.metal new file mode 100644 index 0000000..36102b2 --- /dev/null +++ b/vr-dive/Demos/Stereographic/StereographicShaders.metal @@ -0,0 +1,51 @@ +#include +using namespace metal; + +struct StereoSceneUniforms { + float time; + uint layerCount; + float2 padding; +}; + +struct StereographicVertex { + float3 position; + float3 normal; + float4 color; +}; + +struct StereoVertexOut { + float4 position [[position]]; + float3 normal; + float4 color; +}; + +vertex StereoVertexOut stereographicVertexShader( + ushort amplificationID [[amplification_id]], + const device StereographicVertex *vertices [[buffer(0)]], + constant StereoSceneUniforms &uniforms [[buffer(2)]], + constant float4x4 *viewProjectionMatrices [[buffer(3)]], + uint vertexID [[vertex_id]]) { + StereoVertexOut out; + + uint layers = max(uniforms.layerCount, 1u); + uint viewIndex = min((uint)amplificationID, layers - 1); + + StereographicVertex vtx = vertices[vertexID]; + + out.position = viewProjectionMatrices[viewIndex] * float4(vtx.position, 1.0); + out.normal = normalize(vtx.normal); + out.color = vtx.color; + return out; +} + +fragment float4 stereographicFragmentShader(StereoVertexOut in [[stage_in]], + bool isFrontFacing [[front_facing]]) { + float3 normal = isFrontFacing ? normalize(in.normal) : -normalize(in.normal); + float3 lightDir = normalize(float3(0.35, 0.85, -0.25)); + float diffuse = max(dot(normal, lightDir), 0.0); + float rim = pow(1.0 - max(dot(normal, float3(0.0, 0.0, 1.0)), 0.0), 2.0); + float brightness = 0.22 + diffuse * 0.7 + rim * 0.2; + + float3 color = in.color.rgb * brightness; + return float4(color, 1.0); +} diff --git a/vr-dive/Demos/Stereographic/StereographicTypes.swift b/vr-dive/Demos/Stereographic/StereographicTypes.swift new file mode 100644 index 0000000..3bfaae8 --- /dev/null +++ b/vr-dive/Demos/Stereographic/StereographicTypes.swift @@ -0,0 +1,7 @@ +import simd + +struct StereographicVertex { + var position: SIMD3 + var normal: SIMD3 + var color: SIMD4 +} diff --git a/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetRenderer.swift b/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetRenderer.swift new file mode 100644 index 0000000..3ace476 --- /dev/null +++ b/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetRenderer.swift @@ -0,0 +1,184 @@ +import Metal +import simd + +// SynthwaveSunsetRenderer.swift +// +// Renders a synthwave landscape and sky using fragment ray marching. +// A large inward-facing box surrounds the viewer, and each face acts as a portal +// into the raymarched scene. +// +// Source: ShaderToy "another synthwave sunset thing" +// https://www.shadertoy.com/view/tsScRK + +final class SynthwaveSunsetRenderer: VisualPatternController { + let identifier: VisualPatternKind = .synthwaveSunset + 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 boxHalfExtents = SIMD3(1.0, 1.0, 1.0) + private let objectCenter = SIMD3(0.0, -0.05, -1.75) + private let sceneSpeed: Float = 2.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 = SynthwaveSunsetRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try SynthwaveSunsetRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = SynthwaveSunsetRenderer.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) + + let viewerOffset = SIMD3(repeating: 0) + + var uniforms = SynthwaveSunsetUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + sceneSpeed: sceneSpeed, + _pad: 0, + objectCenter: SIMD4(objectCenter.x, objectCenter.y, objectCenter.z, 0), + viewerOffset: SIMD4(viewerOffset.x, viewerOffset.y, viewerOffset.z, 0), + boxHalfExtents: SIMD4(boxHalfExtents.x, boxHalfExtents.y, boxHalfExtents.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 SynthwaveSunsetRenderer { + fileprivate func averageViewerCenter(from transforms: [simd_float4x4]) -> SIMD3 { + guard !transforms.isEmpty else { return .zero } + var center = SIMD3(repeating: 0) + for transform in transforms { + center += SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) + } + return center / Float(transforms.count) + } + + 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: "synthwaveSunsetVertex") + desc.fragmentFunction = library.makeFunction(name: "synthwaveSunsetFragment") + 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/SynthwaveSunset/SynthwaveSunsetShaders.metal b/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetShaders.metal new file mode 100644 index 0000000..65d7789 --- /dev/null +++ b/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetShaders.metal @@ -0,0 +1,252 @@ +// SynthwaveSunsetShaders.metal +// +// Source: ShaderToy "another synthwave sunset thing" +// https://www.shadertoy.com/view/tsScRK +// +// VR adaptation notes: +// - Converted from mainImage full-screen rendering to a view-aligned box mesh. +// - Stereo, audio texture sampling, and AA branches are removed for visionOS cost. +// - The raymarch scene uses the real headset view rays while preserving the original +// trinoise terrain / sun / starfield look. + +#include +using namespace metal; + +struct SynthwaveSunsetUniforms { + float time; + uint viewCount; + float sceneSpeed; + float _pad; + float4 objectCenter; + float4 viewerOffset; + float4 boxHalfExtents; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct SynthwaveVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex SynthwaveVertexOut synthwaveSunsetVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant SynthwaveSunsetUniforms &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.boxHalfExtents.xyz + uniforms.objectCenter.xyz; + + SynthwaveVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +#define SW_SPEED 10.0f + +static float sw_amp(float2 p) { + return smoothstep(1.0f, 8.0f, abs(p.x)); +} + +static float sw_pow512(float a) { + a *= a; + a *= a; + a *= a; + a *= a; + a *= a; + a *= a; + a *= a; + a *= a; + return a * a; +} + +static float sw_pow1d5(float a) { + return a * sqrt(max(a, 0.0f)); +} + +static float sw_hash21(float2 co) { + return fract(sin(dot(co, float2(1.9898f, 7.233f))) * 45758.5433f); +} + +static float sw_hash(float2 uv, float t) { + float a = sw_amp(uv); + float w = a > 0.0f + ? (1.0f - 0.4f * sw_pow512(0.51f + 0.49f * sin((0.02f * (uv.y + 0.5f * uv.x) - t) * 2.0f))) + : 0.0f; + return a > 0.0f ? a * sw_pow1d5(sw_hash21(uv)) * w : 0.0f; +} + +static float sw_edgeMin(float dx, float2 da, float2 db, float2 uv) { + uv.x += 5.0f; + float3 c = fract(round(float3(uv, uv.x + uv.y)) * (float3(0.0f, 1.0f, 2.0f) + 0.61803398875f)); + float a1 = sw_hash21(float2(c.y, c.x + 17.0f)) > 0.6f ? 0.15f : 1.0f; + float a2 = sw_hash21(float2(c.x, c.z + 23.0f)) > 0.6f ? 0.15f : 1.0f; + float a3 = sw_hash21(float2(c.z, c.y + 31.0f)) > 0.6f ? 0.15f : 1.0f; + return min(min((1.0f - dx) * db.y * a3, da.x * a2), da.y * a1); +} + +static float2 sw_trinoise(float2 uv, float t) { + const float sq = 1.22474487139f; // sqrt(3/2) + uv.x *= sq; + uv.y -= 0.5f * uv.x; + float2 d = fract(uv); + uv -= d; + + bool c = dot(d, float2(1.0f)) > 1.0f; + float2 dd = 1.0f - d; + float2 da = c ? dd : d; + float2 db = c ? d : dd; + + float nn = sw_hash(uv + float(c), t); + float n2 = sw_hash(uv + float2(1.0f, 0.0f), t); + float n3 = sw_hash(uv + float2(0.0f, 1.0f), t); + + float nmid = mix(n2, n3, d.y); + float ns = mix(nn, c ? n2 : n3, da.y); + float dx = da.x / max(db.y, 1e-4f); + return float2(mix(ns, nmid, dx), sw_edgeMin(dx, da, db, uv + d)); +} + +static float2 sw_map(float3 p, float t) { + float2 n = sw_trinoise(p.xz, t); + return float2(p.y - 2.0f * n.x, n.y); +} + +static float3 sw_grad(float3 p, float t, float eps) { + const float2 base = float2(1.0f, 0.0f); + float2 e = base * eps; + float a = sw_map(p, t).x; + return float3( + sw_map(p + e.xyy, t).x - a, + sw_map(p + e.yxy, t).x - a, + sw_map(p + e.yyx, t).x - a) / e.x; +} + +static float2 sw_intersect(float3 ro, float3 rd, float t, int maxSteps, float maxDist, float hitScale) { + float d = 0.0f; + float h = 0.0f; + for (int i = 0; i < maxSteps; ++i) { + float3 p = ro + d * rd; + float2 s = sw_map(p, t); + h = s.x; + d += h * 0.5f; + if (abs(h) < hitScale * max(d, 1.0f)) { + return float2(d, s.y); + } + if (d > maxDist || p.y > 2.5f) { + break; + } + } + return float2(-1.0f); +} + +static float sw_frontDetail(float3 rd) { + float front = smoothstep(-0.15f, 0.35f, rd.z); + float side = 1.0f - smoothstep(0.35f, 0.95f, abs(rd.x)); + float down = 1.0f - smoothstep(0.2f, 0.75f, rd.y); + return clamp(max(front * side, 0.18f) * mix(0.7f, 1.0f, down), 0.18f, 1.0f); +} + +static void sw_addSun(float3 rd, float3 ld, thread float3 &col) { + float sun = smoothstep(0.21f, 0.2f, distance(rd, ld)); + if (sun > 0.0f) { + float yd = rd.y - ld.y; + float a = sin(3.1f * exp(-yd * 14.0f)); + sun *= smoothstep(-0.8f, 0.0f, a); + col = mix(col, float3(1.0f, 0.8f, 0.4f) * 0.75f, sun); + } +} + +static float sw_starNoise(float3 rd) { + float c = 0.0f; + float3 p = normalize(rd) * 300.0f; + for (float i = 0.0f; i < 2.0f; i += 1.0f) { + float3 q = fract(p) - 0.5f; + float3 id = floor(p); + float c2 = smoothstep(0.5f, 0.0f, length(q)); + c2 *= step(sw_hash21(id.xz / max(abs(id.y), 1.0f)), 0.06f - i * i * 0.005f); + c += c2; + p = p * 0.6f + 0.5f * p * float3x3( + float3(3.0f / 5.0f, 0.0f, 4.0f / 5.0f), + float3(0.0f, 1.0f, 0.0f), + float3(-4.0f / 5.0f, 0.0f, 3.0f / 5.0f)); + } + c *= c; + float g = dot(sin(rd * 10.512f), cos(rd.yzx * 10.512f)); + c *= smoothstep(-3.14f, -0.9f, g) * 0.5f + 0.5f * smoothstep(-0.3f, 1.0f, g); + return c * c; +} + +static float3 sw_sky(float3 rd, float3 ld, bool mask, float detail) { + float haze = exp2(-(3.2f + 1.2f * detail) * (abs(rd.y) - 0.18f * dot(rd, ld))); + float st = (mask && detail > 0.55f) ? sw_starNoise(rd) * detail * (1.0f - min(haze, 1.0f)) : 0.0f; + float horizonGlow = exp2(-(0.08f + 0.08f * detail) * abs(length(rd.xz) / max(abs(rd.y), 0.08f))); + float3 back = float3(0.32f, 0.08f, 0.52f) * (1.0f - 0.18f * horizonGlow * max(sign(rd.y), 0.0f)); + float3 col = clamp(mix(back, float3(0.7f, 0.1f, 0.4f), haze) + st, 0.0f, 1.0f); + if (mask) { + float3 sunCol = col; + sw_addSun(rd, ld, sunCol); + col = mix(col, sunCol, 0.4f + 0.6f * detail); + } + return col; +} + +fragment float4 synthwaveSunsetFragment( + SynthwaveVertexOut in [[stage_in]], + constant SynthwaveSunsetUniforms &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 worldRay = normalize(in.worldPos - camWorld); + float3 rd = normalize(float3(worldRay.x, worldRay.y, -worldRay.z)); + float detail = sw_frontDetail(rd); + + float t = fmod(uniforms.time, 4000.0f); + float3 viewerOffset = uniforms.viewerOffset.xyz; + float3 ro = float3( + viewerOffset.x * 8.0f, + 1.0f + viewerOffset.y * 3.0f, + -160.0f + t * uniforms.sceneSpeed + viewerOffset.z * 8.0f); + + bool traceTerrain = (rd.y < 0.32f) || (detail > 0.55f); + int maxSteps = int(mix(36.0f, 96.0f, detail)); + float maxDist = mix(70.0f, 130.0f, detail); + float hitScale = mix(0.008f, 0.0035f, detail); + float2 hit = traceTerrain ? sw_intersect(ro, rd, t, maxSteps, maxDist, hitScale) : float2(-1.0f); + float d = hit.x; + float3 ld = normalize(float3(0.0f, 0.125f + 0.05f * sin(0.1f * t), 1.0f)); + + float3 sky = sw_sky(rd, ld, d < 0.0f, detail); + float3 col = sky; + + if (d > 0.0f) { + float3 fog = exp2(-d * mix(float3(0.18f, 0.13f, 0.32f), float3(0.14f, 0.1f, 0.28f), detail)); + float3 p = ro + d * rd; + float3 n = normalize(sw_grad(p, t, mix(0.03f, 0.012f, detail))); + + float diff = max(dot(n, ld) + 0.1f * n.y, 0.0f); + col = float3(0.1f, 0.11f, 0.18f) * diff; + + float3 reflectedRay = reflect(rd, n); + float3 reflectedColor = sw_sky(reflectedRay, ld, detail > 0.55f, detail * 0.85f); + float fresnel = (0.02f + 0.45f * detail) * pow(max(1.0f + dot(rd, n), 0.0f), 5.0f); + col = mix(col, reflectedColor, fresnel); + col = mix(col, float3(0.8f, 0.1f, 0.92f), smoothstep(0.03f, 0.0f, hit.y) * detail); + col = mix(sky, col, fog); + } + + col = sqrt(clamp(col, 0.0f, 1.0f)); + return float4(col, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetTypes.swift b/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetTypes.swift new file mode 100644 index 0000000..d28f811 --- /dev/null +++ b/vr-dive/Demos/SynthwaveSunset/SynthwaveSunsetTypes.swift @@ -0,0 +1,13 @@ +import simd + +/// Must stay in sync with the Metal struct SynthwaveSunsetUniforms in +/// SynthwaveSunsetShaders.metal. +struct SynthwaveSunsetUniforms { + var time: Float + var viewCount: UInt32 + var sceneSpeed: Float + var _pad: Float + var objectCenter: SIMD4 + var viewerOffset: SIMD4 + var boxHalfExtents: SIMD4 +} diff --git a/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalRenderer.swift b/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalRenderer.swift new file mode 100644 index 0000000..b72e2cc --- /dev/null +++ b/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// TesseractCornerFractalRenderer.swift +// +// Cube-container adaptation of ShaderToy "Tesseract Corner Fractal" (7fs3Wf). +// 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 TesseractCornerFractalRenderer: VisualPatternController { + let identifier: VisualPatternKind = .tesseractCornerFractal + 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 = TesseractCornerFractalRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try TesseractCornerFractalRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = TesseractCornerFractalRenderer.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 = TesseractCornerFractalUniforms( + 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 TesseractCornerFractalRenderer { + 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: "tesseractCornerFractalVertex") + desc.fragmentFunction = library.makeFunction(name: "tesseractCornerFractalFragment") + 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/TesseractCornerFractal/TesseractCornerFractalShaders.metal b/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalShaders.metal new file mode 100644 index 0000000..6f1db1d --- /dev/null +++ b/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalShaders.metal @@ -0,0 +1,219 @@ +// TesseractCornerFractalShaders.metal +// Adapted from ShaderToy "Tesseract Corner Fractal". +// Source: https://www.shadertoy.com/view/7fs3Wf +// +// Metal adaptation notes: +// - The original shader used a fixed screen-space camera in a 4D field. +// This version reconstructs the real per-eye world ray, intersects it with a +// 2 m cube container, and starts marching at the visible cube surface or at +// the eye when the viewer is inside the cube. +// - The simulated 4D fractal is traced beyond the container boundary, so the +// content is not clipped to the cube volume. +// - GLSL's row-vector style `p *= m` is implemented explicitly for Metal. + +#include +using namespace metal; + +struct TesseractCornerFractalUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct TesseractCornerFractalVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float3 TCF_BOX_HALF = float3(1.0f); +static constant float TCF_TRACE_EPSILON = 0.0015f; +static constant float TCF_HIT_EPSILON = 0.00012f; +static constant float TCF_SCENE_SCALE = 2.2f; +static constant float TCF_MAX_DISTANCE = 18.0f; +static constant int TCF_TRACE_STEPS = 96; + +vertex TesseractCornerFractalVertexOut tesseractCornerFractalVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant TesseractCornerFractalUniforms &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; + + TesseractCornerFractalVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 tcfBoxIntersect(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 float4 tcfMulRow(float4 v, float4x4 m) { + return float4(dot(v, m[0]), dot(v, m[1]), dot(v, m[2]), dot(v, m[3])); +} + +static float4x4 tcfRotationPair(float t1, float t2) { + return float4x4( + float4(cos(t1), sin(t1), 0.0f, 0.0f), + float4(-sin(t1), cos(t1), 0.0f, 0.0f), + float4(0.0f, 0.0f, cos(t2), sin(t2)), + float4(0.0f, 0.0f, -sin(t2), cos(t2))); +} + +static float4x4 tcfPermutation() { + return float4x4( + float4(0.0f, 1.0f, 0.0f, 0.0f), + float4(0.0f, 0.0f, 1.0f, 0.0f), + float4(0.0f, 0.0f, 0.0f, 1.0f), + float4(1.0f, 0.0f, 0.0f, 0.0f)); +} + +static float4x4 tcfBuildRotation(float time) { + float4x4 rot = float4x4(1.0f); + float t1 = time * 0.3f; + float t2 = time * 0.3f; + + for (int j = 0; j < 8; ++j) { + rot = rot * tcfRotationPair(t1, t2); + rot = rot * tcfPermutation(); + t1 /= -1.237415f; + t2 /= 1.348912f; + } + return rot; +} + +static float tcfMax4(float4 v) { + return max(max(v.x, v.y), max(v.z, v.w)); +} + +static float tcfSdf(float4 p, float4x4 m) { + float q = 1.0f; + float d = 1.0e9f; + for (int n = 0; n < 4; ++n) { + p = tcfMulRow(p, m); + p = abs(p); + + float cornerA = max(p.x, max(p.y, p.z)); + float cornerB = max(p.y, max(p.z, p.w)); + float cornerC = max(p.z, max(p.w, p.x)); + float cornerD = max(p.w, max(p.x, p.y)); + + float outer = tcfMax4(p) - 1.0f; + float inner = 0.8f - min(min(cornerA, cornerB), min(cornerC, cornerD)); + d = min(d, max(outer, inner) / q); + + p = (p - 0.9f) * 2.1f; + q *= 2.1f; + } + return d; +} + +static float3 tcfCalcNormal(float4 p, float4x4 rot) { + float e = 0.001f; + float dx = tcfSdf(p + float4(e, 0.0f, 0.0f, 0.0f), rot) - tcfSdf(p - float4(e, 0.0f, 0.0f, 0.0f), rot); + float dy = tcfSdf(p + float4(0.0f, e, 0.0f, 0.0f), rot) - tcfSdf(p - float4(0.0f, e, 0.0f, 0.0f), rot); + float dz = tcfSdf(p + float4(0.0f, 0.0f, e, 0.0f), rot) - tcfSdf(p - float4(0.0f, 0.0f, e, 0.0f), rot); + return normalize(float3(dx, dy, dz)); +} + +fragment float4 tesseractCornerFractalFragment( + TesseractCornerFractalVertexOut in [[stage_in]], + constant TesseractCornerFractalUniforms &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) < TCF_BOX_HALF - 1.0e-3f); + float2 tBox = tcfBoxIntersect(eye, rd, TCF_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 + TCF_TRACE_EPSILON); + + float4x4 rot = tcfBuildRotation(uniforms.time); + float wOrigin = 0.35f * sin(uniforms.time * 0.37f); + float wDirection = 0.22f * sin(uniforms.time * 0.19f + dot(marchOrigin, float3(1.1f, -0.8f, 0.6f))); + + float4 rayPos = float4(marchOrigin * TCF_SCENE_SCALE, wOrigin); + float4 rayDir = normalize(float4(rd, wDirection)); + + float brightness = 1.0f; + float traveled = 0.0f; + float stepDistance = 0.0f; + bool hitSurface = false; + int stepsTaken = 0; + + for (int i = 0; i < TCF_TRACE_STEPS; ++i) { + stepsTaken = i; + stepDistance = tcfSdf(rayPos, rot); + if (stepDistance < TCF_HIT_EPSILON) { + hitSurface = true; + break; + } + + float safeStep = max(stepDistance, TCF_HIT_EPSILON * 1.25f); + traveled += safeStep; + rayPos += rayDir * safeStep; + brightness /= 1.07f; + + if (traveled > TCF_MAX_DISTANCE) { + break; + } + } + + float3 baseFog = mix( + float3(0.02f, 0.03f, 0.06f), + float3(0.16f, 0.22f, 0.34f), + clamp(0.5f + 0.5f * rd.y, 0.0f, 1.0f)); + float3 color = baseFog * (0.25f + 0.75f * brightness); + + if (hitSurface) { + float3 normal = tcfCalcNormal(rayPos, rot); + float3 lightDir = normalize(float3(0.45f, 0.72f, -0.53f)); + float diffuse = clamp(dot(normal, lightDir), 0.0f, 1.0f); + float backLight = clamp(0.2f + 0.8f * dot(normal, normalize(float3(-0.4f, 0.3f, 0.85f))), 0.0f, 1.0f); + float rim = pow(1.0f - clamp(dot(normal, -rd), 0.0f, 1.0f), 2.5f); + + float3 stripe = 0.5f + 0.5f * cos(2.8f * rayPos.w + float3(0.0f, 1.8f, 3.6f)); + float3 albedo = mix(float3(0.09f, 0.12f, 0.22f), float3(0.78f, 0.86f, 1.0f), stripe); + float attenuation = exp(-0.065f * traveled * traveled); + + color = albedo * (0.18f + 0.95f * diffuse + 0.35f * backLight); + color += float3(0.45f, 0.55f, 0.9f) * rim * 0.55f; + color *= attenuation * (0.5f + 0.5f * brightness); + } + + color = sqrt(clamp(color, 0.0f, 1.0f)); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalTypes.swift b/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalTypes.swift new file mode 100644 index 0000000..1c525b2 --- /dev/null +++ b/vr-dive/Demos/TesseractCornerFractal/TesseractCornerFractalTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct TesseractCornerFractalUniforms in TesseractCornerFractalShaders.metal. +struct TesseractCornerFractalUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/TorusFan/TorusFanRenderer.swift b/vr-dive/Demos/TorusFan/TorusFanRenderer.swift new file mode 100644 index 0000000..65c56ed --- /dev/null +++ b/vr-dive/Demos/TorusFan/TorusFanRenderer.swift @@ -0,0 +1,168 @@ +import Metal +import simd + +// TorusFanRenderer.swift +// +// Adapts ShaderToy "4tS3Dc" into a 3D spatial demo rendered inside a +// 2 m × 2 m × 2 m cube container. + +final class TorusFanRenderer: VisualPatternController { + let identifier: VisualPatternKind = .torusFan + 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 = TorusFanRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(1, 1, 1) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try TorusFanRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = TorusFanRenderer.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 = TorusFanUniforms( + time: animationTime, + viewCount: UInt32(context.viewData.viewCount), + boxScale: boxScale, + _pad: 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 TorusFanRenderer { + 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: "torusFanVertex") + desc.fragmentFunction = library.makeFunction(name: "torusFanFragment") + 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/TorusFan/TorusFanShaders.metal b/vr-dive/Demos/TorusFan/TorusFanShaders.metal new file mode 100644 index 0000000..4573659 --- /dev/null +++ b/vr-dive/Demos/TorusFan/TorusFanShaders.metal @@ -0,0 +1,221 @@ +// TorusFanShaders.metal +// Adapted from Stephane Cuillerdier (Aiekick), 2015. +// Source: https://www.shadertoy.com/view/4tS3Dc +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// This version renders the original torus-field effect inside a transparent +// 2 m × 2 m × 2 m cube container. The original ShaderToy samples a cubemap for +// reflection and background. This project does not bind a cubemap texture for +// container demos, so reflection is approximated with a procedural environment. + +#include +using namespace metal; + +struct TorusFanUniforms { + float time; + uint viewCount; + float boxScale; + float _pad; + float4 objectCenter; +}; + +struct TorusFanMeshVertex { + float3 position; + float3 normal; +}; + +struct TorusFanVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct TorusFanState { + float3 dstepf; + float3 colFact; +}; + +vertex TorusFanVertexOut torusFanVertex( + ushort amplificationID [[amplification_id]], + const device TorusFanMeshVertex *vertices [[buffer(0)]], + constant TorusFanUniforms &uniforms [[buffer(1)]], + constant float4x4 *vpMatrices [[buffer(2)]], + uint vertexID [[vertex_id]]) +{ + TorusFanMeshVertex vtx = vertices[vertexID]; + uint viewIndex = min((uint)amplificationID, uniforms.viewCount - 1u); + float3 worldPos = vtx.position * uniforms.boxScale + uniforms.objectCenter.xyz; + + TorusFanVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float tf_dota(thread TorusFanState &state, float3 a, float3 b) { + state.dstepf.y += state.colFact.y; + return dot(a, b); +} + +static float3 tf_nora(thread TorusFanState &state, float3 a) { + state.dstepf.z += state.colFact.z; + return normalize(a); +} + +static float2 tf_uvMap(float3 p) { + p = normalize(p); + return float2( + 0.5f + atan2(p.z, p.x) / 6.28318530718f, + 0.5f - asin(clamp(p.y, -1.0f, 1.0f)) / 3.14159265359f); +} + +static float2 tf_getTemp(thread TorusFanState &state, float3 p, float time) { + p *= 2.0f; + float2 p2 = tf_uvMap(p); + float2 coef = float2(30.0f, 100.0f * (sin(time * 0.1f) * 0.5f + 0.5f)); + float r = fract(p2.x * coef.x + cos(p2.y * coef.y)); + return float2(tf_dota(state, p, p) * 100.0f * r, r); +} + +static float3 tf_getHotColor(float temperature) { + float safeTemperature = max(temperature, 1.0f); + float3 col = float3(255.0f); + col.x = 56100000.0f * pow(safeTemperature, -1.5f) + 148.0f; + col.y = 100.04f * log(safeTemperature) - 623.6f; + if (safeTemperature > 6500.0f) { + col.y = 35200000.0f * pow(safeTemperature, -1.5f) + 184.0f; + } + col.z = 194.18f * log(safeTemperature) - 1448.6f; + col = clamp(col, 0.0f, 255.0f) / 255.0f; + if (safeTemperature < 1000.0f) { + col *= safeTemperature / 1000.0f; + } + return col; +} + +static float tf_sdTorus(float3 p, float2 t) { + float2 q = float2(length(p.xz) - t.x, p.y); + return length(q) - t.y; +} + +static float3 tf_rotateSample(float3 p, float time) { + float angleY = time * 0.35f; + float cy = cos(angleY); + float sy = sin(angleY); + float2 xz = float2(cy * p.x - sy * p.z, sy * p.x + cy * p.z); + p.x = xz.x; + p.z = xz.y; + + float angleX = time * 0.19f; + float cx = cos(angleX); + float sx = sin(angleX); + float2 yz = float2(cx * p.y - sx * p.z, sx * p.y + cx * p.z); + p.y = yz.x; + p.z = yz.y; + return p; +} + +static float4 tf_map(thread TorusFanState &state, float3 p, float time) { + state.dstepf.x += state.colFact.x; + float3 samplePoint = tf_rotateSample(p, time); + float2 temp = tf_getTemp(state, samplePoint, time); + float3 col = tf_getHotColor(temp.x); + float disp = tf_dota(state, col, -state.dstepf.xyx); + float dist = tf_sdTorus(samplePoint, float2(6.0f, 3.0f)) - disp; + return float4(dist, col); +} + +static float3 tf_nor(thread TorusFanState &state, float3 p, float prec, float time) { + float2 e = float2(prec, 0.0f); + return normalize(float3( + tf_map(state, p + e.xyy, time).x - tf_map(state, p - e.xyy, time).x, + tf_map(state, p + e.yxy, time).x - tf_map(state, p - e.yxy, time).x, + tf_map(state, p + e.yyx, time).x - tf_map(state, p - e.yyx, time).x)); +} + +static float3 tf_environment(float3 dir) { + 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), 5.0f); + float3 sky = mix(float3(0.03f, 0.04f, 0.08f), float3(0.15f, 0.22f, 0.36f), skyMix); + float3 glow = float3(1.0f, 0.52f, 0.16f) * horizon; + return sky + glow * 0.45f; +} + +static float4 tf_boxIntersect(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); + float nearT = max(max(tMin.x, tMin.y), tMin.z); + float farT = min(min(tMax.x, tMax.y), tMax.z); + return float4(nearT, farT, 0.0f, 0.0f); +} + +fragment float4 torusFanFragment( + TorusFanVertexOut in [[stage_in]], + constant TorusFanUniforms &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 scale = uniforms.boxScale; + float3 eye = (camWorld - center) / scale; + float3 hit = (in.worldPos - center) / scale; + + TorusFanState state; + state.dstepf = float3(0.0f); + state.colFact = float3(0.0006f, 0.0004f, 0.17f); + state.colFact.y = state.colFact.y * (sin(uniforms.time * 2.0f) * 0.5f + 0.5f) + state.colFact.y; + state.colFact.x = state.colFact.y * (sin(uniforms.time) * 0.5f + 0.5f) + state.colFact.x / 3.0f; + state.colFact.z = 0.5f * (sin(uniforms.time * 0.5f) * 0.5f + 0.5f) + 0.1f; + + float3 rd = tf_nora(state, hit - eye); + const float3 halfBox = float3(1.0f); + bool insideBox = all(abs(eye) < halfBox - 1e-3f); + float4 tBox = tf_boxIntersect(eye, rd, halfBox); + 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(); + } + + const float modelScale = 8.0f; + float3 ro = (eye + rd * tStart) * modelScale; + float maxDistance = min(50.0f, (tEnd - tStart) * modelScale); + + float distanceTraveled = 0.0f; + float stepDistance = 0.001f; + float3 p = ro; + for (int i = 0; i < 500; i++) { + if (stepDistance < 0.001f || stepDistance > 50.0f || distanceTraveled > maxDistance) { + break; + } + float4 mapped = tf_map(state, p, uniforms.time); + stepDistance = mapped.x * (stepDistance > 0.001f ? 0.3f : 0.05f); + distanceTraveled += stepDistance; + p = ro + rd * distanceTraveled; + } + + if (distanceTraveled >= maxDistance || stepDistance > 50.0f) { + discard_fragment(); + } + + float3 normal = tf_nor(state, p, 0.01f, uniforms.time); + float3 reflected = reflect(rd, normal); + float3 environment = tf_environment(reflected) * 0.6f; + float4 surface = tf_map(state, p, uniforms.time); + float3 color = environment + float3(pow(0.55f, 15.0f)); + color = mix(color, surface.yzw, 0.5f); + color += state.dstepf; + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/TorusFan/TorusFanTypes.swift b/vr-dive/Demos/TorusFan/TorusFanTypes.swift new file mode 100644 index 0000000..6cb7642 --- /dev/null +++ b/vr-dive/Demos/TorusFan/TorusFanTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct TorusFanUniforms in TorusFanShaders.metal. +struct TorusFanUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var _pad: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Renderer.swift b/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Renderer.swift new file mode 100644 index 0000000..84d2e76 --- /dev/null +++ b/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Renderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// TorusKnotInR4Renderer.swift +// +// Cube-container adaptation of ShaderToy "tsBGzt". +// 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 TorusKnotInR4Renderer: VisualPatternController { + let identifier: VisualPatternKind = .torusKnotInR4 + 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 = TorusKnotInR4Renderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try TorusKnotInR4Renderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = TorusKnotInR4Renderer.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 = TorusKnotInR4Uniforms( + 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 TorusKnotInR4Renderer { + 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: "torusKnotInR4Vertex") + desc.fragmentFunction = library.makeFunction(name: "torusKnotInR4Fragment") + 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/TorusKnotInR4/TorusKnotInR4Shaders.metal b/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Shaders.metal new file mode 100644 index 0000000..3a11383 --- /dev/null +++ b/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Shaders.metal @@ -0,0 +1,217 @@ +// TorusKnotInR4Shaders.metal +// Adapted from ShaderToy "tsBGzt" by S.Guillitte. +// Source: https://www.shadertoy.com/view/tsBGzt +// Based on https://www.shadertoy.com/view/4ds3zn by inigo quilez (iq), 2013. +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Metal adaptation notes: +// - The original shader uses a synthetic orbit 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 fractal field is traced beyond the cube entry plane, so the simulated +// structure is not clipped by the container volume. + +#include +using namespace metal; + +struct TorusKnotInR4Uniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct TorusKnotInR4VertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +struct TK4State { + float4 orb; + float ss; + float blendSelector; +}; + +static constant float TK4_G = 0.85f; +static constant float3 TK4_CSIZE = float3(1.0f, 0.8f, 1.1f); +static constant float3 TK4_BOX_HALF = float3(1.0f); +static constant float TK4_SCENE_SCALE = 2.6f; +static constant float TK4_MAX_DISTANCE = 100.0f; +static constant float TK4_TRACE_EPSILON = 0.002f; +static constant int TK4_TRACE_STEPS = 200; + +vertex TorusKnotInR4VertexOut torusKnotInR4Vertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant TorusKnotInR4Uniforms &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; + + TorusKnotInR4VertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float3 tk4Fold(float3 p) { + p = (-1.0f + 2.0f * fract(0.5f * p * TK4_CSIZE + 0.5f)) / TK4_CSIZE; + p = clamp(p, -TK4_CSIZE, TK4_CSIZE) * 2.0f - p; + return p; +} + +static float tk4MapWithIterations(float3 p, int iterationCount, thread TK4State &state) { + float scale = 1.0f; + state.orb = float4(1000.0f); + + for (int i = 0; i < iterationCount; ++i) { + p = tk4Fold(p); + float r2 = max(dot(p, p), 1.0e-5f); + state.orb = min(state.orb, float4(abs(p), r2)); + + float k = max(state.ss / r2, 0.1f) * TK4_G; + p *= k; + scale *= k; + } + + float d1 = 0.2f * ( + length(p.xz) * abs(p.y) + + length(p.xy) * abs(p.z) + + length(p.yz) * abs(p.x)) / scale; + float d2 = 0.25f * abs(p.y) / scale; + return state.blendSelector * d1 + (1.0f - state.blendSelector) * d2; +} + +static float tk4Map(float3 p, thread TK4State &state) { + return tk4MapWithIterations(p, 8, state); +} + +static float tk4MapApprox(float3 p, thread TK4State &state) { + return tk4MapWithIterations(p, 3, state); +} + +static float2 tk4BoxIntersect(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 tk4Trace(float3 ro, float3 rd, thread TK4State &state, thread float4 &orbOut) { + const float precis = 0.001f; + const float approx = 0.1f; + + float h = precis * 2.0f; + float t = 0.0f; + orbOut = float4(1000.0f); + + for (int i = 0; i < TK4_TRACE_STEPS; ++i) { + if (t > TK4_MAX_DISTANCE) { + break; + } + + if (abs(h) > approx || t > 20.0f) { + t += h; + h = tk4MapApprox(ro + rd * t, state); + orbOut = state.orb; + continue; + } + + if (abs(h) < precis * (1.0f + 0.2f * t)) { + break; + } + + t += h; + h = tk4Map(ro + rd * t, state); + orbOut = state.orb; + } + + return t > TK4_MAX_DISTANCE ? -1.0f : t; +} + +static float3 tk4CalcNormal(float3 pos, thread TK4State &state) { + float3 eps = float3(0.0001f, 0.0f, 0.0f); + float dx = tk4Map(pos + eps.xyy, state) - tk4Map(pos - eps.xyy, state); + float dy = tk4Map(pos + eps.yxy, state) - tk4Map(pos - eps.yxy, state); + float dz = tk4Map(pos + eps.yyx, state) - tk4Map(pos - eps.yyx, state); + return normalize(float3(dx, dy, dz)); +} + +fragment float4 torusKnotInR4Fragment( + TorusKnotInR4VertexOut in [[stage_in]], + constant TorusKnotInR4Uniforms &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) < TK4_BOX_HALF - 1.0e-3f); + float2 tBox = tk4BoxIntersect(eye, rd, TK4_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 + TK4_TRACE_EPSILON); + + TK4State state; + float baseTime = uniforms.time; + float morph = cos(0.1f * baseTime); + state.blendSelector = step(0.0f, morph); + state.ss = 1.3f - 0.2f * morph; + state.orb = float4(1000.0f); + + float3 ro = marchOrigin * TK4_SCENE_SCALE; + float4 trap = float4(1000.0f); + float t = tk4Trace(ro, rd, state, trap); + if (t <= 0.0f) { + discard_fragment(); + } + + float3 pos = ro + t * rd; + float3 nor = tk4CalcNormal(pos, state); + + 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.00f) * key * ao; + brdf += float3(0.40f) * bac * ao; + + float3 rgb = + 0.4f * abs(sin(4.5f + float3(trap.w, trap.y * trap.y, 2.0f - trap.w))) + + 0.6f * sin(float3(-0.5f, -0.2f, 0.8f) + 1.3f + trap.x * 9.5f); + + float3 color = rgb * brdf * exp(-0.2f * t); + color = sqrt(max(color, 0.0f)); + color = mix(color, smoothstep(0.0f, 1.0f, color), 0.25f); + + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Types.swift b/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Types.swift new file mode 100644 index 0000000..f031ca4 --- /dev/null +++ b/vr-dive/Demos/TorusKnotInR4/TorusKnotInR4Types.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct TorusKnotInR4Uniforms in TorusKnotInR4Shaders.metal. +struct TorusKnotInR4Uniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/Tunnel/TunnelRenderer.swift b/vr-dive/Demos/Tunnel/TunnelRenderer.swift new file mode 100644 index 0000000..4e17c6c --- /dev/null +++ b/vr-dive/Demos/Tunnel/TunnelRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// TunnelRenderer.swift +// +// Original implementation for a cube-portal tunnel scene. +// Visual inspiration requested from ShaderToy 4dfGDr: +// https://www.shadertoy.com/view/4dfGDr +// The original shader source is not reused here; this renderer drives a +// clean-room Metal implementation adapted for the app's cube container. + +final class TunnelRenderer: VisualPatternController { + let identifier: VisualPatternKind = .tunnel + 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 = 1.0 + 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 = TunnelRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try TunnelRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = TunnelRenderer.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 = TunnelUniforms( + 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 TunnelRenderer { + 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: "tunnelVertex") + desc.fragmentFunction = library.makeFunction(name: "tunnelFragment") + 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/Tunnel/TunnelShaders.metal b/vr-dive/Demos/Tunnel/TunnelShaders.metal new file mode 100644 index 0000000..b69e38f --- /dev/null +++ b/vr-dive/Demos/Tunnel/TunnelShaders.metal @@ -0,0 +1,200 @@ +// TunnelShaders.metal +// +// Original implementation for a cube-contained tunnel field. +// Visual inspiration requested from ShaderToy 4dfGDr. +// Reference link: https://www.shadertoy.com/view/4dfGDr +// The source from the reference shader is not reused here. This is a +// clean-room Metal implementation that preserves the high-level idea of an +// animated segmented tunnel rendered after the ray enters the cube container. + +#include +using namespace metal; + +#define TU_PI 3.14159265359f +#define TU_TWO_PI 6.28318530718f +#define TU_MAX_STEPS 192 +#define TU_MAX_DIST 96.0f +#define TU_EPS 0.0010f +#define TU_SCENE_SCALE 14.0f + +struct TunnelUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct TunnelVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex TunnelVertexOut tunnelVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant TunnelUniforms &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; + + TunnelVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float tuPeriodic(float value, float period, float dutyCycle) { + float normalized = value / period; + float centered = abs(normalized - floor(normalized) - 0.5f) - dutyCycle * 0.5f; + return centered * period; +} + +static float tuCount(float value, float period) { + return floor(value / period); +} + +static float3 tuPalette(float t) { + constexpr float3 stops[13] = { + float3(0.00f, 0.00f, 0.00f), + float3(0.01f, 0.06f, 0.20f), + float3(0.03f, 0.08f, 0.28f), + float3(0.10f, 0.03f, 0.36f), + float3(0.28f, 0.02f, 0.47f), + float3(0.52f, 0.03f, 0.54f), + float3(0.72f, 0.07f, 0.44f), + float3(0.88f, 0.12f, 0.20f), + float3(0.98f, 0.30f, 0.12f), + float3(0.99f, 0.63f, 0.28f), + float3(0.99f, 0.90f, 0.55f), + float3(0.94f, 0.98f, 0.80f), + float3(0.96f, 1.00f, 0.92f) + }; + + float x = clamp(t, 0.0f, 0.999f) * 12.0f; + int idx = min((int)floor(x), 11); + float f = fract(x); + return mix(stops[idx], stops[idx + 1], f); +} + +static float tuDistanceField(float3 p, float time) { + float radial = length(p.xy); + float angle = atan2(p.y, p.x); + + float radialBand = tuCount(radial, 3.0f); + float depthBand = tuCount(p.z, 1.0f); + float temporalGate = 0.80f + 0.12f * cos(time / 3.0f); + float swirl = time * 0.3f * sin(radialBand + 1.0f) * sin(depthBand * 13.73f); + float sweptAngle = angle + swirl; + float angularPeriod = max(radial, 0.001f) * (TU_TWO_PI / 6.0f); + + float radialShell = tuPeriodic(radial, 3.0f, 0.26f); + float axialSlice = tuPeriodic(p.z, 1.0f, temporalGate); + float spokeSlice = tuPeriodic(sweptAngle * radial, angularPeriod, temporalGate); + return min(max(max(radialShell, axialSlice), spokeSlice), 0.25f); +} + +static float3 tuBackground(float3 rd) { + float horizon = exp(-9.0f * abs(rd.y)); + float3 col = float3(0.001f, 0.0008f, 0.004f); + col += horizon * float3(0.01f, 0.004f, 0.02f); + return col; +} + +static bool tuBoxHit( + float3 ro, float3 rd, float3 bmin, float3 bmax, + thread float &tNear, thread float &tFar) +{ + float3 invDir = 1.0f / max(abs(rd), 1e-4f) * sign(rd); + float3 t0 = (bmin - ro) * invDir; + float3 t1 = (bmax - ro) * invDir; + 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 bool tuTrace( + float3 ro, float3 rd, float time, thread float &hitT, thread float &stepsTaken) +{ + float travel = 0.0f; + float marchedSteps = float(TU_MAX_STEPS); + + for (int i = 0; i < TU_MAX_STEPS; ++i) { + float3 pos = ro + rd * travel; + float distanceToSurface = tuDistanceField(pos, time); + marchedSteps = float(i); + if (abs(distanceToSurface) < TU_EPS) { + hitT = travel; + stepsTaken = marchedSteps; + return true; + } + + travel += distanceToSurface; + if (travel > TU_MAX_DIST) break; + } + + hitT = travel; + stepsTaken = marchedSteps; + return false; +} + +fragment float4 tunnelFragment( + TunnelVertexOut in [[stage_in]], + constant TunnelUniforms &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 cubeScale = uniforms.cubeScale; + float3 rdWorld = normalize(in.worldPos - camWorld); + + float3 roLocal = (camWorld - center) / cubeScale; + float3 rdLocal = rdWorld; + + float tEntry; + float tExit; + if (!tuBoxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float3 entryLocal = roLocal + rdLocal * max(tEntry, 0.0f); + float sceneTime = uniforms.time * uniforms.travelSpeed; + float3 sceneRo = entryLocal * TU_SCENE_SCALE; + sceneRo.z += sceneTime; + float3 sceneRd = rdLocal; + + float sceneNear; + float sceneFar; + if (!tuBoxHit(sceneRo, sceneRd, float3(-48.0f), float3(48.0f), sceneNear, sceneFar)) { + return float4(tuBackground(sceneRd), 1.0f); + } + + sceneRo += sceneRd * max(sceneNear, 0.0f); + + float hitT; + float stepsTaken; + bool hit = tuTrace(sceneRo, sceneRd, sceneTime, hitT, stepsTaken); + if (!hit) { + return float4(tuBackground(sceneRd), 1.0f); + } + + float progress = clamp(stepsTaken / float(TU_MAX_STEPS), 0.0f, 1.0f); + float3 color = tuPalette(progress); + color = pow(max(color, 0.0f), float3(1.08f)); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/Tunnel/TunnelTypes.swift b/vr-dive/Demos/Tunnel/TunnelTypes.swift new file mode 100644 index 0000000..471ef6e --- /dev/null +++ b/vr-dive/Demos/Tunnel/TunnelTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct TunnelUniforms in TunnelShaders.metal. +struct TunnelUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracRenderer.swift b/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracRenderer.swift new file mode 100644 index 0000000..ede9765 --- /dev/null +++ b/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracRenderer.swift @@ -0,0 +1,174 @@ +import Metal +import simd + +// TunnelingThroughApollianFracRenderer.swift +// Cube-container adaptation of ShaderToy "Tunneling through apollian fractals" (tlcBWH). + +final class TunnelingThroughApollianFracRenderer: VisualPatternController { + let identifier: VisualPatternKind = .tunnelingThroughApollianFrac + 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, -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 = TunnelingThroughApollianFracRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try TunnelingThroughApollianFracRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = TunnelingThroughApollianFracRenderer.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 = TunnelingThroughApollianFracUniforms( + 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 TunnelingThroughApollianFracRenderer { + 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: "tunnelingThroughApollianFracVertex") + desc.fragmentFunction = library.makeFunction(name: "tunnelingThroughApollianFracFragment") + 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/TunnelingThroughApollianFrac/TunnelingThroughApollianFracShaders.metal b/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracShaders.metal new file mode 100644 index 0000000..e90375b --- /dev/null +++ b/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracShaders.metal @@ -0,0 +1,405 @@ +// TunnelingThroughApollianFracShaders.metal +// "Tunneling through apollian fractals" — cube-container adaptation of ShaderToy tlcBWH. +// Source: https://www.shadertoy.com/view/tlcBWH +// License: CC0. +// +// Adaptation notes: +// - The original shader uses a synthetic tunnel camera that advances along a +// procedural 3D path and alpha-composites a stack of patterned planes. +// - This version reconstructs a real per-eye ray from the visible 2 m cube, +// then maps that ray into the original tunnel camera frame so the content is +// visible from all viewing directions and when the viewer is inside the cube. + +#include +using namespace metal; + +struct TunnelingThroughApollianFracUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct TunnelingThroughApollianFracVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float TT_PI = 3.141592654f; +static constant float TT_TAU = 6.283185307f; +static constant float3 TT_STD_GAMMA = float3(2.2f); +static constant float TT_SCENE_SCALE = 0.5f; +static constant float3 TT_BOX_HALF = float3(1.0f); +static constant int TT_FURTHEST = 8; +static constant int TT_FADE_FROM = 5; + +vertex TunnelingThroughApollianFracVertexOut tunnelingThroughApollianFracVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant TunnelingThroughApollianFracUniforms &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; + + TunnelingThroughApollianFracVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 ttRotate(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 ttHash(float x) { + return fract(sin(x * 12.9898f) * 13758.5453f); +} + +static float2 ttToPolar(float2 p) { + return float2(length(p), atan2(p.y, p.x)); +} + +static float2 ttToRect(float2 p) { + return float2(p.x * cos(p.y), p.x * sin(p.y)); +} + +static float ttTanhApprox(float x) { + float x2 = x * x; + return clamp(x * (27.0f + x2) / (27.0f + 9.0f * x2), -1.0f, 1.0f); +} + +static float ttPMin(float a, float b, float k) { + float h = clamp(0.5f + 0.5f * (b - a) / k, 0.0f, 1.0f); + return mix(b, a, h) - k * h * (1.0f - h); +} + +static float3 ttHsv2rgb(float3 c) { + const float4 K = float4(1.0f, 0.6666667f, 0.3333333f, 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 ttApollian(float4 p, float s) { + float scale = 1.0f; + for (int i = 0; i < 7; ++i) { + p = -1.0f + 2.0f * fract(0.5f * p + 0.5f); + float r2 = dot(p, p); + float k = s / max(r2, 1.0e-5f); + p *= k; + scale *= k; + } + return abs(p.y) / scale; +} + +static float ttHex(float2 p, float r) { + const float3 k = float3(-0.86602540378f, 0.5f, 0.57735026919f); + p = p.yx; + p = abs(p); + p -= 2.0f * min(dot(k.xy, p), 0.0f) * k.xy; + p -= float2(clamp(p.x, -k.z * r, k.z * r), r); + return length(p) * sign(p.y); +} + +static float ttCircle(float2 p, float r) { + return length(p) - r; +} + +static float ttModMirror1(thread float &p, float size) { + float halfsize = size * 0.5f; + float c = floor((p + halfsize) / size); + p = fmod(p + halfsize, size); + if (p < 0.0f) { + p += size; + } + p -= halfsize; + p *= fmod(c, 2.0f) * 2.0f - 1.0f; + return c; +} + +static float ttSabs(float x, float k) { + float ax = abs(x); + float quad = (0.5f / k) * x * x + k * 0.5f; + return mix(quad, ax, step(0.0f, ax - k)); +} + +static float ttSmoothKaleidoscope(thread float2 &p, float sm, float rep) { + float2 polar = ttToPolar(p); + float angle = polar.y; + float rn = ttModMirror1(angle, TT_TAU / rep); + polar.y = angle; + float sa = TT_PI / rep - ttSabs(TT_PI / rep - abs(polar.y), sm); + polar.y = copysign(sa, polar.y); + p = ttToRect(polar); + return rn; +} + +static float4 ttAlphaBlend(float4 back, float4 front) { + float w = front.w + back.w * (1.0f - front.w); + if (w <= 0.0f) { + return float4(0.0f); + } + float3 xyz = (front.xyz * front.w + back.xyz * back.w * (1.0f - front.w)) / w; + return float4(xyz, w); +} + +static float3 ttAlphaBlend3(float3 back, float4 front) { + return mix(back, front.xyz, front.w); +} + +static float3 ttOffset(float z) { + float a = z; + float2 p = -0.10f * ( + float2(cos(a), sin(a * sqrt(2.0f))) + + float2(cos(a * sqrt(0.75f)), sin(a * sqrt(0.5f)))); + return float3(p, z); +} + +static float3 ttDOffset(float z) { + float eps = 0.1f; + return 0.5f * (ttOffset(z + eps) - ttOffset(z - eps)) / eps; +} + +static float3 ttDDOffset(float z) { + float eps = 0.1f; + return 0.125f * (ttDOffset(z + eps) - ttDOffset(z - eps)) / eps; +} + +static float ttWeird(float2 p, float h, float time) { + float z = 4.0f; + float tm = 0.1f * time + h * 10.0f; + p = ttRotate(p, tm * 0.5f); + float r = 0.5f; + float4 off = float4( + r * (0.5f + 0.5f * sin(tm * sqrt(3.0f))), + r * (0.5f + 0.5f * sin(tm * sqrt(1.5f))), + r * (0.5f + 0.5f * sin(tm * sqrt(2.0f))), + 0.0f); + float4 pp = float4(p.x, p.y, 0.0f, 0.0f) + off; + pp.w = 0.125f * (1.0f - ttTanhApprox(length(pp.xyz))); + pp.yz = ttRotate(pp.yz, tm); + pp.xz = ttRotate(pp.xz, tm * sqrt(0.5f)); + pp /= z; + return ttApollian(pp, 0.8f + h) * z; +} + +static float ttCircles(float2 p) { + float2 pp = ttToPolar(p); + const float ss = 0.25f; + pp.x = fract(pp.x * ss) / ss; + p = ttToRect(pp); + return ttCircle(p, 1.0f); +} + +static float ttOnionize(float d) { + d = abs(d) - 0.02f; + d = abs(d) - 0.005f; + d = abs(d) - 0.0025f; + return d; +} + +static float2 ttDf(float2 p, float h, float time) { + float2 wp = p; + float rep = 10.0f; + float ss = 0.05f * 6.0f / rep; + ttSmoothKaleidoscope(wp, ss, rep); + + float d0 = ttWeird(wp, h, time); + d0 = ttOnionize(d0); + float d1 = ttHex(p, 0.25f) - 0.1f; + float d2 = ttCircles(p); + const float lw = 0.0125f; + d2 = abs(d2) - lw; + float d = ttPMin(ttPMin(d0, d2, 0.1f), abs(d1) - lw, 0.05f); + return float2(d, d1 + lw); +} + +static float4 ttPlane(float3 ro, float3 rd, float3 pp, float3 off, float aa, float n, float time) { + float h = ttHash(n); + float s = 0.25f * mix(0.5f, 0.25f, h); + float dd = length(pp - ro); + + const float3 nor = float3(0.0f, 0.0f, 1.0f); + const float3 loff = float3(0.125f, 0.0625f, -0.125f); + float3 lp1 = ro + loff; + float3 lp2 = ro + loff * float3(-1.0f, 1.0f, 1.0f); + float3 ld1 = normalize(pp - lp1); + float3 ld2 = normalize(pp - lp2); + float ref1 = pow(max(dot(nor, ld1), 0.0f), 20.0f); + float ref2 = pow(max(dot(nor, ld2), 0.0f), 20.0f); + float3 col1 = float3(0.75f, 0.5f, 1.0f); + float3 col2 = float3(1.0f, 0.5f, 0.75f); + + float2 p = (pp - off * float3(1.0f, 1.0f, 0.0f)).xy; + p = ttRotate(p, TT_TAU * h); + float2 d2 = ttDf(p / s, h, time) * s; + + float ha = smoothstep(-aa, aa, d2.y); + float d = d2.x; + float4 col = float4(0.0f); + + float l = length(10.0f * p); + float ddf = 1.0f / (1.0f + 2.0f * dd); + float hue = fract(0.75f * l - 0.1f * time) + 0.45f; + float sat = 0.75f * ttTanhApprox(2.0f * l) * ddf; + float vue = sqrt(ddf); + float3 bcol = ttHsv2rgb(float3(hue, sat, vue)); + col.xyz = mix(col.xyz, bcol, smoothstep(-aa, aa, -d)); + float glow = exp(-(10.0f + 100.0f * ttTanhApprox(l)) * 10.0f * max(d, 0.0f) * ddf); + col.xyz += 0.5f * sqrt(bcol.zxy) * glow; + col.w = ha * mix(0.75f, 1.0f, ha * glow); + col.xyz += 0.125f * col.w * (col1 * ref1 + col2 * ref2); + + return col; +} + +static float3 ttSkyColor(float3 rd) { + float ld = max(dot(rd, float3(0.0f, 0.0f, 1.0f)), 0.0f); + return 1.25f * float3(1.0f, 0.75f, 0.85f) * ttTanhApprox(3.0f * pow(ld, 100.0f)); +} + +static float3 ttColorAlongPath(float3 ww, float3 uu, float3 vv, float3 ro, float2 p, float time) { + float2 np = p + float2(0.0015f, 0.0015f); + float rdd = 2.0f + 0.5f * ttTanhApprox(length(p)); + float3 rd = normalize(p.x * uu + p.y * vv + rdd * ww); + float3 nrd = normalize(np.x * uu + np.y * vv + rdd * ww); + if (abs(rd.z) < 1.0e-4f || abs(nrd.z) < 1.0e-4f) { + return ttSkyColor(rd); + } + + const float planeDist = 0.25f; + const float fadeDist = planeDist * float(TT_FURTHEST - TT_FADE_FROM); + float nz = floor(ro.z / planeDist); + float3 skyCol = ttSkyColor(rd); + + float4 accum = float4(0.0f); + const float cutOff = 0.95f; + for (int i = 1; i <= TT_FURTHEST; ++i) { + float planeIndex = nz + float(i); + float pz = planeDist * planeIndex; + float pd = (pz - ro.z) / rd.z; + if (pd > 0.0f && accum.w < cutOff) { + float3 pp = ro + rd * pd; + float3 npp = ro + nrd * pd; + float aa = 3.0f * length(pp - npp); + float3 off = ttOffset(pp.z); + + float4 pcol = ttPlane(ro, rd, pp, off, aa, planeIndex, time); + float dz = pp.z - ro.z; + float fadeIn = exp(-2.5f * max((dz - planeDist * float(TT_FADE_FROM)) / max(fadeDist, 1.0e-4f), 0.0f)); + float fadeOut = smoothstep(0.0f, planeDist * 0.1f, dz); + pcol.xyz = mix(skyCol, pcol.xyz, fadeIn); + pcol.w *= fadeOut; + pcol = clamp(pcol, 0.0f, 1.0f); + accum = ttAlphaBlend(pcol, accum); + } else if (pd > 0.0f) { + break; + } + } + + return ttAlphaBlend3(skyCol, accum); +} + +static float3 ttPostProcess(float3 col, float2 q) { + col = clamp(col, 0.0f, 1.0f); + col = pow(col, 1.0f / TT_STD_GAMMA); + col = col * 0.6f + 0.4f * col * col * (3.0f - 2.0f * col); + col = mix(col, float3(dot(col, float3(0.33f))), -0.4f); + col *= 0.5f + 0.5f * pow(max(19.0f * q.x * q.y * (1.0f - q.x) * (1.0f - q.y), 0.0f), 0.7f); + return col; +} + +static float2 ttBoxIntersect(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 ttFaceUV(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); +} + +fragment float4 tunnelingThroughApollianFracFragment( + TunnelingThroughApollianFracVertexOut in [[stage_in]], + constant TunnelingThroughApollianFracUniforms &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) < TT_BOX_HALF - 1.0e-3f); + float2 tOuter = ttBoxIntersect(eye, viewDir, TT_BOX_HALF); + if (!insideOuter && tOuter.x > tOuter.y) { + discard_fragment(); + } + + float tStart = insideOuter ? 0.0f : max(tOuter.x, 0.0f); + float3 localOrigin = eye + viewDir * (tStart + 0.001f); + + float time = uniforms.time; + float tm = time * 0.2f; + float3 pathOrigin = ttOffset(tm); + float3 tangent = normalize(ttDOffset(tm)); + float3 curvature = ttDDOffset(tm); + float3 binormal = normalize(cross(normalize(float3(0.0f, 1.0f, 0.0f) + curvature), tangent)); + if (!all(isfinite(binormal)) || length(binormal) < 1.0e-4f) { + binormal = normalize(cross(float3(1.0f, 0.0f, 0.0f), tangent)); + } + float3 normal = cross(tangent, binormal); + + float3 sceneOrigin = localOrigin * TT_SCENE_SCALE; + sceneOrigin.xy += pathOrigin.xy; + sceneOrigin.z += tm; + + float3 ww = tangent; + float3 uu = binormal; + float3 vv = normal; + float2 p = float2(dot(viewDir, uu), dot(viewDir, vv)) / max(dot(viewDir, ww), 0.22f); + + float3 col = ttColorAlongPath(ww, uu, vv, sceneOrigin, p, time); + float trail = pow(clamp(1.0f - abs(dot(viewDir, ww)), 0.0f, 1.0f), 2.0f); + float3 background = float3(0.008f, 0.01f, 0.016f); + background += float3(0.18f, 0.08f, 0.24f) * (0.04f + 0.08f * trail); + + float2 faceUV = ttFaceUV(surfacePos) * 2.0f - 1.0f; + float vignette = 1.0f - 0.18f * dot(faceUV, faceUV); + + col += background; + col = ttPostProcess(col, ttFaceUV(surfacePos)); + col = sqrt(max(col, 0.0f)); + col *= vignette; + return float4(clamp(col, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracTypes.swift b/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracTypes.swift new file mode 100644 index 0000000..6d8a74a --- /dev/null +++ b/vr-dive/Demos/TunnelingThroughApollianFrac/TunnelingThroughApollianFracTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct TunnelingThroughApollianFracUniforms in +/// TunnelingThroughApollianFracShaders.metal. +struct TunnelingThroughApollianFracUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/VoxelEdges/VoxelEdgesRenderer.swift b/vr-dive/Demos/VoxelEdges/VoxelEdgesRenderer.swift new file mode 100644 index 0000000..f25c193 --- /dev/null +++ b/vr-dive/Demos/VoxelEdges/VoxelEdgesRenderer.swift @@ -0,0 +1,169 @@ +import Metal +import simd + +// VoxelEdgesRenderer.swift +// +// Original implementation for a voxel edges scene rendered through a cube portal. +// Visual inspiration was requested from ShaderToy 4dfGzs, but the original source +// license forbids reuse in products/projects. This implementation is original and +// only follows the high-level idea of a voxel landscape with glowing edge lines. + +final class VoxelEdgesRenderer: VisualPatternController { + let identifier: VisualPatternKind = .voxelEdges + 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.7 + 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 = VoxelEdgesRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try VoxelEdgesRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = VoxelEdgesRenderer.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 = VoxelEdgesUniforms( + 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 VoxelEdgesRenderer { + 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: "voxelEdgesVertex") + desc.fragmentFunction = library.makeFunction(name: "voxelEdgesFragment") + 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/VoxelEdges/VoxelEdgesShaders.metal b/vr-dive/Demos/VoxelEdges/VoxelEdgesShaders.metal new file mode 100644 index 0000000..69a785d --- /dev/null +++ b/vr-dive/Demos/VoxelEdges/VoxelEdgesShaders.metal @@ -0,0 +1,415 @@ +// VoxelEdgesShaders.metal +// +// Original implementation for a voxel edges cube portal. +// Visual inspiration was requested from ShaderToy 4dfGzs. +// Reference link: https://www.shadertoy.com/view/4dfGzs +// Shading article reference: https://iquilezles.org/articles/voxellines +// The original source license forbids reuse in products/projects, so no source +// code from the original work is copied here. This shader is a clean-room +// implementation of a voxel landscape shown in a constant glowing-edge style. + +#include +using namespace metal; + +#define VE_VOXEL_SIZE 1.44225f +#define VE_WORLD_MIN int3(-25, -6, -31) +#define VE_WORLD_MAX int3(25, 24, 25) +#define VE_MAX_TRACE_STEPS 58 +#define VE_MAX_TRACE_DIST 62.0f + +struct VoxelEdgesUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct VoxelEdgesVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex VoxelEdgesVertexOut voxelEdgesVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant VoxelEdgesUniforms &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; + + VoxelEdgesVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float ve_hash21(float2 p) { + p = fract(p * float2(0.1031f, 0.1030f)); + p += dot(p, p.yx + 19.19f); + return fract((p.x + p.y) * p.x); +} + +static float ve_noise2(float2 p) { + float2 i = floor(p); + float2 f = fract(p); + f = f * f * (3.0f - 2.0f * f); + float a = ve_hash21(i + float2(0.0f, 0.0f)); + float b = ve_hash21(i + float2(1.0f, 0.0f)); + float c = ve_hash21(i + float2(0.0f, 1.0f)); + float d = ve_hash21(i + float2(1.0f, 1.0f)); + return mix(mix(a, b, f.x), mix(c, d, f.x), f.y); +} + +static float ve_hash31(float3 p) { + p = fract(p * 0.1031f); + p += dot(p, p.yzx + 31.32f); + return fract((p.x + p.y) * p.z); +} + +static float ve_noise3(float3 p) { + float3 i = floor(p); + float3 f = fract(p); + f = f * f * (3.0f - 2.0f * f); + + float n000 = ve_hash31(i + float3(0.0f, 0.0f, 0.0f)); + float n100 = ve_hash31(i + float3(1.0f, 0.0f, 0.0f)); + float n010 = ve_hash31(i + float3(0.0f, 1.0f, 0.0f)); + float n110 = ve_hash31(i + float3(1.0f, 1.0f, 0.0f)); + float n001 = ve_hash31(i + float3(0.0f, 0.0f, 1.0f)); + float n101 = ve_hash31(i + float3(1.0f, 0.0f, 1.0f)); + float n011 = ve_hash31(i + float3(0.0f, 1.0f, 1.0f)); + float n111 = ve_hash31(i + float3(1.0f, 1.0f, 1.0f)); + + float nx00 = mix(n000, n100, f.x); + float nx10 = mix(n010, n110, f.x); + float nx01 = mix(n001, n101, f.x); + float nx11 = mix(n011, n111, f.x); + float nxy0 = mix(nx00, nx10, f.y); + float nxy1 = mix(nx01, nx11, f.y); + return mix(nxy0, nxy1, f.z); +} + +static float ve_fbm(float2 p) { + float sum = 0.0f; + float amp = 0.5f; + float2 pp = p; + for (int i = 0; i < 3; ++i) { + sum += amp * ve_noise2(pp); + pp = float2(1.7f * pp.x - 1.2f * pp.y, 1.2f * pp.x + 1.7f * pp.y); + amp *= 0.5f; + } + return sum; +} + +static float ve_terrainHeight(float2 xz, float time) { + float2 p = xz * 0.085f + float2(0.0f, time * 0.18f); + float broad = ve_fbm(p); + float detail = ve_fbm(p * 2.1f + float2(7.1f, -3.7f)); + float dunes = 0.9f * sin(xz.x * 0.07f + time * 0.4f) + 0.6f * cos(xz.y * 0.05f - time * 0.3f); + return -1.0f + 10.0f * broad + 3.5f * detail + dunes; +} + +static float ve_terrainField(float3 p, float time) { + float3 samplePos = p * 0.1f; + samplePos.xz *= 0.6f; + + float animTime = 0.5f + 0.15f * time; + float ft = fract(animTime); + float it = floor(animTime); + ft = smoothstep(0.7f, 1.0f, ft); + animTime = it + ft; + float speed = 1.4f; + + float field = 0.5f * ve_noise3(samplePos * 1.00f + float3(0.0f, 1.0f, 0.0f) * speed * animTime); + field += 0.25f * ve_noise3(samplePos * 2.02f + float3(0.0f, 2.0f, 0.0f) * speed * animTime); + field += 0.125f * ve_noise3(samplePos * 4.01f); + return 25.0f * field - 10.0f; +} + +static bool ve_insideWorld(int3 cell) { + return all(cell >= VE_WORLD_MIN) && all(cell <= VE_WORLD_MAX); +} + +static float ve_voxelValue(int3 cell, float time, float3 cameraOrigin) { + if (!ve_insideWorld(cell)) return false; + float3 p = float3(cell) + 0.5f; + float density = ve_terrainField(p, time) + 0.25f * p.y; + float cameraClear = step(length(cameraOrigin - p), 5.0f); + density = mix(density, 1.0f, cameraClear); + return density <= 0.5f ? 1.0f : 0.0f; +} + +static bool ve_voxelSolid(int3 cell, float time, float3 cameraOrigin) { + return ve_voxelValue(cell, time, cameraOrigin) > 0.5f; +} + +static bool ve_isFrontBoundaryOutside(int3 cell, float3 viewDir) { + bool outsideXMin = cell.x < VE_WORLD_MIN.x && viewDir.x > 0.0f; + bool outsideXMax = cell.x > VE_WORLD_MAX.x && viewDir.x < 0.0f; + bool outsideYMin = cell.y < VE_WORLD_MIN.y && viewDir.y > 0.0f; + bool outsideYMax = cell.y > VE_WORLD_MAX.y && viewDir.y < 0.0f; + bool outsideZMin = cell.z < VE_WORLD_MIN.z && viewDir.z > 0.0f; + bool outsideZMax = cell.z > VE_WORLD_MAX.z && viewDir.z < 0.0f; + return outsideXMin || outsideXMax || outsideYMin || outsideYMax || outsideZMin || outsideZMax; +} + +static float ve_neighborValue(int3 cell, float time, float3 cameraOrigin, float3 viewDir) { + if (ve_insideWorld(cell)) { + return ve_voxelValue(cell, time, cameraOrigin); + } + return ve_isFrontBoundaryOutside(cell, viewDir) ? 0.0f : 1.0f; +} + +static float3 ve_sky(float3 rd) { + float up = clamp(rd.y * 0.5f + 0.5f, 0.0f, 1.0f); + float horizon = exp(-8.0f * abs(rd.y)); + float3 col = mix(float3(0.005f, 0.006f, 0.010f), float3(0.018f, 0.014f, 0.012f), up); + col += horizon * float3(0.020f, 0.010f, 0.004f); + return col; +} + +static bool ve_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 bool ve_trace( + float3 ro, float3 rd, float time, float3 cameraOrigin, thread float &hitT, + thread float3 &hitPos, thread float3 &hitNormal, thread int3 &hitCell) +{ + float3 dir = normalize(rd); + float3 cellf = floor(ro); + int3 cell = int3(cellf); + float3 stepDir = sign(dir); + int3 stepI = int3(stepDir); + float3 nextBoundary = cellf + step(float3(0.0f), stepDir); + + float3 invDir = 1.0f / max(abs(dir), 1e-4f); + float3 tDelta = invDir; + float3 tMax = abs((nextBoundary - ro) * invDir); + + float currentT = 0.0f; + float3 currentNormal = float3(0.0f, 1.0f, 0.0f); + + for (int i = 0; i < VE_MAX_TRACE_STEPS; ++i) { + if (ve_insideWorld(cell) && ve_voxelSolid(cell, time, cameraOrigin)) { + hitT = currentT; + hitPos = ro + dir * currentT; + hitNormal = currentNormal; + hitCell = cell; + return true; + } + + if (tMax.x < tMax.y && tMax.x < tMax.z) { + currentT = tMax.x; + tMax.x += tDelta.x; + cell.x += stepI.x; + currentNormal = float3(-stepDir.x, 0.0f, 0.0f); + } else if (tMax.y < tMax.z) { + currentT = tMax.y; + tMax.y += tDelta.y; + cell.y += stepI.y; + currentNormal = float3(0.0f, -stepDir.y, 0.0f); + } else { + currentT = tMax.z; + tMax.z += tDelta.z; + cell.z += stepI.z; + currentNormal = float3(0.0f, 0.0f, -stepDir.z); + } + + if (currentT > VE_MAX_TRACE_DIST) break; + } + + return false; +} + +static float2 ve_faceUV(float3 pos, float3 normal) { + float3 local = fract(pos); + if (abs(normal.x) > 0.5f) return local.zy; + if (abs(normal.y) > 0.5f) return local.xz; + return local.xy; +} + +static void ve_faceAxes(float3 normal, thread int3 &axisA, thread int3 &axisB) { + if (abs(normal.x) > 0.5f) { + axisA = int3(0, 1, 0); + axisB = int3(0, 0, 1); + } else if (abs(normal.y) > 0.5f) { + axisA = int3(1, 0, 0); + axisB = int3(0, 0, 1); + } else { + axisA = int3(1, 0, 0); + axisB = int3(0, 1, 0); + } +} + +static float ve_max4(float4 v) { + return max(max(v.x, v.y), max(v.z, v.w)); +} + +static float ve_contourMask(float2 uv, float4 va, float4 vb, float4 vc, float4 vd) { + float2 st = 1.0f - uv; + float4 edgeWeights = smoothstep(0.85f, 0.99f, float4(uv.x, st.x, uv.y, st.y)) + * (1.0f - va + va * vc); + float4 cornerWeights = smoothstep( + 0.85f, 0.99f, + float4(uv.x * uv.y, st.x * uv.y, st.x * st.y, uv.x * st.y)) + * (1.0f - vb + vd * vb); + return ve_max4(max(edgeWeights, cornerWeights)); +} + +static bool ve_sceneHit( + float3 ro, float3 rd, + thread float &tNear, thread float &tFar) +{ + float3 bmin = float3(VE_WORLD_MIN) * VE_VOXEL_SIZE; + float3 bmax = (float3(VE_WORLD_MAX) + 1.0f) * VE_VOXEL_SIZE; + return ve_boxHit(ro, rd, bmin, bmax, tNear, tFar); +} + +fragment float4 voxelEdgesFragment( + VoxelEdgesVertexOut in [[stage_in]], + constant VoxelEdgesUniforms &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 cubeScale = uniforms.cubeScale; + float3 worldDir = normalize(in.worldPos - camWorld); + float3 rd = normalize(worldDir); + + float3 roLocal = (camWorld - center) / cubeScale; + float3 rdLocal = rd; + + float tEntry; + float tExit; + if (!ve_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float3 entryLocal = roLocal + rdLocal * max(tEntry, 0.0f); + float sceneTime = uniforms.time * uniforms.travelSpeed; + float3 sceneRo = entryLocal * 18.0f + float3(0.0f, 6.0f, -18.0f); + float3 sceneRd = rdLocal; + + float sceneTNear; + float sceneTFar; + if (!ve_sceneHit(sceneRo, sceneRd, sceneTNear, sceneTFar)) { + return float4(ve_sky(sceneRd), 1.0f); + } + + float startT = max(sceneTNear, 0.0f); + sceneRo += sceneRd * startT; + + float hitT; + float3 hitPos; + float3 hitNormal; + int3 hitCell = int3(0); + float3 color = ve_sky(sceneRd); + + float3 voxelRo = sceneRo / VE_VOXEL_SIZE; + float3 voxelRd = sceneRd; + float3 cameraOrigin = voxelRo; + + if (ve_trace(voxelRo, voxelRd, sceneTime, cameraOrigin, hitT, hitPos, hitNormal, hitCell)) { + float3 sceneHitPos = hitPos * VE_VOXEL_SIZE; + float2 uv = ve_faceUV(hitPos, hitNormal); + + int3 normalCell = int3(int(hitNormal.x), int(hitNormal.y), int(hitNormal.z)); + int3 axisA; + int3 axisB; + ve_faceAxes(hitNormal, axisA, axisB); + + float4 vc = float4( + ve_neighborValue(hitCell + normalCell + axisA, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + normalCell - axisA, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + normalCell + axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + normalCell - axisB, sceneTime, cameraOrigin, voxelRd)); + float4 vd = float4( + ve_neighborValue(hitCell + normalCell + axisA + axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + normalCell - axisA + axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + normalCell - axisA - axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + normalCell + axisA - axisB, sceneTime, cameraOrigin, voxelRd)); + float4 va = float4( + ve_neighborValue(hitCell + axisA, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell - axisA, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell - axisB, sceneTime, cameraOrigin, voxelRd)); + float4 vb = float4( + ve_neighborValue(hitCell + axisA + axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell - axisA + axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell - axisA - axisB, sceneTime, cameraOrigin, voxelRd), + ve_neighborValue(hitCell + axisA - axisB, sceneTime, cameraOrigin, voxelRd)); + + float sixNeighborOccupancy = + ve_neighborValue(hitCell + int3(1, 0, 0), sceneTime, cameraOrigin, voxelRd) + + ve_neighborValue(hitCell + int3(-1, 0, 0), sceneTime, cameraOrigin, voxelRd) + + ve_neighborValue(hitCell + int3(0, 1, 0), sceneTime, cameraOrigin, voxelRd) + + ve_neighborValue(hitCell + int3(0, -1, 0), sceneTime, cameraOrigin, voxelRd) + + ve_neighborValue(hitCell + int3(0, 0, 1), sceneTime, cameraOrigin, voxelRd) + + ve_neighborValue(hitCell + int3(0, 0, -1), sceneTime, cameraOrigin, voxelRd); + bool fullyEnclosed = sixNeighborOccupancy > 5.5f; + + bool boundaryFace = ve_isFrontBoundaryOutside(hitCell + normalCell, voxelRd); + + float contourGlow = ve_contourMask(uv, va, vb, vc, vd); + float edgeDistance = min(min(uv.x, 1.0f - uv.x), min(uv.y, 1.0f - uv.y)); + float faceEdge = 1.0f - smoothstep(0.02f, 0.06f, edgeDistance); + float fineEdge = 1.0f - smoothstep(0.008f, 0.022f, edgeDistance); + contourGlow = max(contourGlow, faceEdge * (1.0f - ve_max4(va)) * 0.3f); + float hiddenEdgeMask = boundaryFace ? 0.0f : faceEdge * (1.0f - contourGlow); + + float sky = 0.5f + 0.5f * hitNormal.y; + float ambient = clamp(0.5f + 0.02f * sceneHitPos.y, 0.15f, 1.0f); + float fog = exp(-0.032f * (hitT * VE_VOXEL_SIZE + startT)); + float heightTint = clamp((sceneHitPos.y + 6.0f) / 20.0f, 0.0f, 1.0f); + + float3 fill = mix(float3(0.035f, 0.038f, 0.040f), float3(0.080f, 0.076f, 0.070f), heightTint); + fill *= 0.38f + 0.34f * sky + 0.28f * ambient; + fill *= 1.0f - 0.72f * contourGlow; + + if (boundaryFace) { + float boundaryLight = 0.55f + 0.25f * sky + 0.20f * ambient; + float3 boundaryFill = mix(float3(0.070f, 0.066f, 0.060f), float3(0.125f, 0.115f, 0.100f), heightTint); + fill = boundaryFill * boundaryLight; + fill += fineEdge * 0.06f * float3(0.50f, 0.38f, 0.24f); + } + + float3 hiddenTint = mix(float3(0.014f, 0.013f, 0.012f), float3(0.028f, 0.024f, 0.020f), heightTint); + fill = mix(hiddenTint, fill, clamp(0.25f + 0.75f * contourGlow, 0.0f, 1.0f)); + fill -= hiddenEdgeMask * 0.035f * float3(1.0f, 0.9f, 0.8f); + float3 hiddenEdgeGlow = hiddenEdgeMask * mix(float3(0.050f, 0.030f, 0.012f), float3(0.085f, 0.050f, 0.018f), heightTint); + + float3 edgeGlow = mix(float3(3.2f, 0.55f, 0.02f), float3(5.4f, 0.95f, 0.10f), heightTint); + edgeGlow *= (0.45f + 0.55f * ambient) * contourGlow; + + color = fullyEnclosed ? ve_sky(sceneRd) : (fill + hiddenEdgeGlow + edgeGlow); + color *= fog; + color += 0.22f * ve_sky(sceneRd) * (1.0f - fog); + color = pow(max(color, 0.0f), float3(0.82f)); + } + + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/VoxelEdges/VoxelEdgesTypes.swift b/vr-dive/Demos/VoxelEdges/VoxelEdgesTypes.swift new file mode 100644 index 0000000..1ebabe4 --- /dev/null +++ b/vr-dive/Demos/VoxelEdges/VoxelEdgesTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct VoxelEdgesUniforms in +/// VoxelEdgesShaders.metal. +struct VoxelEdgesUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/VoxelTunnel/VoxelTunnelRenderer.swift b/vr-dive/Demos/VoxelTunnel/VoxelTunnelRenderer.swift new file mode 100644 index 0000000..fb76865 --- /dev/null +++ b/vr-dive/Demos/VoxelTunnel/VoxelTunnelRenderer.swift @@ -0,0 +1,172 @@ +import Metal +import simd + +// VoxelTunnelRenderer.swift +// Source adaptation: @lsdlive, Shadertoy "Voxel tunnel" +// https://www.shadertoy.com/view/MscBRs +// +// This version adapts the original full-screen GLSL shader to a 2.4 m cube +// container so the effect can be viewed from outside or from inside the cube. + +final class VoxelTunnelRenderer: VisualPatternController { + let identifier: VisualPatternKind = .voxelTunnel + 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.2 + private let objectCenter = SIMD3(0.0, -0.02, -2.3) + + private var animationTime: Float = 0 + private var lastSimulationTime: Float? + + init(device: MTLDevice, library: MTLLibrary, maxViewCount: Int) throws { + self.maxViewCount = max(1, maxViewCount) + + let geo = VoxelTunnelRenderer.makeBox( + device: device, localHalfExtents: SIMD3(repeating: 1.0) * 1.01) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try VoxelTunnelRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = VoxelTunnelRenderer.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 = VoxelTunnelUniforms( + 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 VoxelTunnelRenderer { + 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: "voxelTunnelVertex") + desc.fragmentFunction = library.makeFunction(name: "voxelTunnelFragment") + 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/VoxelTunnel/VoxelTunnelShaders.metal b/vr-dive/Demos/VoxelTunnel/VoxelTunnelShaders.metal new file mode 100644 index 0000000..e8d1364 --- /dev/null +++ b/vr-dive/Demos/VoxelTunnel/VoxelTunnelShaders.metal @@ -0,0 +1,196 @@ +// VoxelTunnelShaders.metal +// Source adaptation: @lsdlive, Shadertoy "Voxel tunnel" +// https://www.shadertoy.com/view/MscBRs +// Original algorithm note in the source references fb39ca4 and Shane's DDA +// implementation. This version adapts the effect to a 2.4 m cube container and +// supports rays entering from outside or starting from inside the cube. +// +// GLSL -> Metal adaptation notes: +// - Use an explicit 2D rotation matrix instead of GLSL constructor tricks. +// - Keep the glow accumulator thread-local instead of a mutable global. +// - Treat the visible cube face as a portal when the camera is outside so the +// tunnel can continue beyond the box instead of being clipped at the back face. + +#include +using namespace metal; + +struct VoxelTunnelUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct VoxelTunnelVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex VoxelTunnelVertexOut voxelTunnelVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant VoxelTunnelUniforms &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; + + VoxelTunnelVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static constant float3 VT_BOX_HALF = float3(1.0f, 1.0f, 1.0f); +static constant float VT_SCENE_SCALE = 8.0f; +static constant float VT_OUTSIDE_VIEW_DEPTH = 14.0f; +static constant int VT_MAX_DDA_STEPS = 448; + +static float2 vtR2d(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 float2 vtPath(float t) { + float a = sin(t * 0.2f + 1.5f); + float b = sin(t * 0.2f); + return float2(2.0f * a, a * b); +} + +static float vtDe(float3 p, float time, thread float &glow) { + p.z += time * 6.0f; + p.xy -= vtPath(p.z); + + float d = -length(p.xy) + 4.0f; + + p.xy += float2(cos(p.z + time) * sin(time), cos(p.z + time)); + p.z -= 6.0f + time * 6.0f; + + float3 octSign = sign(p + float3(1e-4f)); + float oct = dot(p, normalize(octSign)) - 1.0f; + d = min(d, oct); + + glow += 0.015f / (0.01f + d * d); + return d; +} + +static float vtBoxHit(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 voxelTunnelFragment( + VoxelTunnelVertexOut in [[stage_in]], + constant VoxelTunnelUniforms &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 = normalize(rdWorld); + + bool insideBox = all(abs(eye) < (VT_BOX_HALF - 1e-3f)); + float3 faceNormal; + float faceT = vtBoxHit(eye, rd, VT_BOX_HALF, faceNormal, !insideBox); + if (faceT < 0.0f) { + discard_fragment(); + } + + float3 facePoint = eye + rd * faceT; + float2 faceCoords = facePoint.xy * faceNormal.z / VT_BOX_HALF.xy + + facePoint.yz * faceNormal.x / VT_BOX_HALF.yz + + facePoint.zx * faceNormal.y / VT_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 start = insideBox ? (eye + rd * 0.002f) : (facePoint + rd * 0.002f); + float maxDistance; + if (insideBox) { + maxDistance = max(faceT - 0.002f, 0.0f); + } else { + float3 exitNormal; + float exitT = vtBoxHit(start, rd, VT_BOX_HALF, exitNormal, false); + float throughCube = exitT > 0.0f ? exitT : 4.0f; + maxDistance = throughCube + VT_OUTSIDE_VIEW_DEPTH; + } + + float3 sceneRo = start * VT_SCENE_SCALE; + float3 sceneRd = normalize(rd); + sceneRo.z = -sceneRo.z; + sceneRd.z = -sceneRd.z; + sceneRd.xy = vtR2d(sceneRd.xy, sin(-sceneRo.x / 3.14f) * 0.3f); + + float3 voxel = floor(sceneRo) + 0.5f; + float3 mask = float3(0.0f); + float3 drd = 1.0f / max(abs(sceneRd), float3(1e-4f)); + float3 raySign = sign(sceneRd + float3(1e-4f)); + float3 side = drd * (raySign * (voxel - sceneRo) + 0.5f); + + float glow = 0.0f; + bool hit = false; + float travel = 0.0f; + float traveledSceneLimit = maxDistance * VT_SCENE_SCALE; + + for (int i = 0; i < VT_MAX_DDA_STEPS; ++i) { + float d = vtDe(voxel, uniforms.time, glow); + if (d < 0.0f) { + hit = true; + break; + } + + mask = step(side, side.yzx) * step(side, side.zxy); + side += drd * mask; + voxel += raySign * mask; + travel = length(voxel - sceneRo); + if (travel > traveledSceneLimit) { + break; + } + } + + float axisMix = length(mask * float3(1.0f, 0.5f, 0.75f)); + float3 color = mix(float3(0.2f, 0.2f, 0.7f), float3(0.2f, 0.1f, 0.2f), axisMix); + color += glow * 0.4f; + color.r += sin(uniforms.time) * 0.2f + sin(-voxel.z * 0.5f - uniforms.time * 6.0f); + color = mix(color, float3(0.2f, 0.1f, 0.2f), 1.0f - exp(-0.00025f * travel * travel)); + + float3 glassBase = mix(float3(0.012f, 0.014f, 0.022f), float3(0.05f, 0.08f, 0.14f), 1.0f - faceFade); + glassBase += edgeGlow * float3(0.08f, 0.13f, 0.22f); + float hitMix = hit ? 1.0f : 0.28f; + color = mix(glassBase, color, hitMix); + float fresnel = pow(clamp(1.0f - abs(dot(faceNormal, rd)), 0.0f, 1.0f), 3.0f); + color += fresnel * float3(0.05f, 0.08f, 0.12f); + color = clamp(tanh(color), 0.0f, 1.0f); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/VoxelTunnel/VoxelTunnelTypes.swift b/vr-dive/Demos/VoxelTunnel/VoxelTunnelTypes.swift new file mode 100644 index 0000000..ccd68ab --- /dev/null +++ b/vr-dive/Demos/VoxelTunnel/VoxelTunnelTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct VoxelTunnelUniforms in +/// VoxelTunnelShaders.metal. +struct VoxelTunnelUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeRenderer.swift b/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeRenderer.swift new file mode 100644 index 0000000..c7c6336 --- /dev/null +++ b/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// WaveLatticeCubeRenderer.swift +// +// Original implementation for a cube-portal wave lattice scene. +// Visual inspiration requested from ShaderToy Ml2XRD: +// https://www.shadertoy.com/view/Ml2XRD +// This implementation is original and does not reuse source code from the +// reference shader. + +final class WaveLatticeCubeRenderer: VisualPatternController { + let identifier: VisualPatternKind = .waveLatticeCube + 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.8 + 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 = WaveLatticeCubeRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try WaveLatticeCubeRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = WaveLatticeCubeRenderer.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 = WaveLatticeCubeUniforms( + 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 WaveLatticeCubeRenderer { + 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: "waveLatticeCubeVertex") + desc.fragmentFunction = library.makeFunction(name: "waveLatticeCubeFragment") + 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/WaveLatticeCube/WaveLatticeCubeShaders.metal b/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeShaders.metal new file mode 100644 index 0000000..bf62fe0 --- /dev/null +++ b/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeShaders.metal @@ -0,0 +1,141 @@ +// WaveLatticeCubeShaders.metal +// +// Original cube-portal wave lattice scene. +// Visual inspiration requested from ShaderToy Ml2XRD: +// https://www.shadertoy.com/view/Ml2XRD +// This implementation is original and does not reuse source code from the +// reference shader. + +#include +using namespace metal; + +#define WLC_MAX_STEPS 96 +#define WLC_MAX_DIST 36.0f +#define WLC_HIT_EPS 0.0010f +#define WLC_SCENE_SCALE 10.0f + +struct WaveLatticeCubeUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct WaveLatticeCubeVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex WaveLatticeCubeVertexOut waveLatticeCubeVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant WaveLatticeCubeUniforms &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; + + WaveLatticeCubeVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 wlc_rot(float2 p, float a) { + float c = cos(a); + float s = sin(a); + return float2(p.x * c - p.y * s, p.x * s + p.y * c); +} + +static float2 wlc_mod(float2 x, float y) { + return x - y * floor(x / y); +} + +static float wlc_map(float3 p) { + float3 n = float3(0.0f, 1.0f, 0.0f); + float k1 = 1.9f; + float k2 = (sin(p.x * k1) + sin(p.z * k1)) * 0.8f; + float k3 = (sin(p.y * k1) + sin(p.z * k1)) * 0.8f; + + float w1 = 4.0f - dot(abs(p), normalize(n)) + k2; + float w2 = 4.0f - dot(abs(p), normalize(n.yzx)) + k3; + + float2 j0 = float2(sin((p.z + p.x) * 2.0f) * 0.3f, cos((p.z + p.x) * 1.0f) * 0.5f); + float2 j1 = float2(sin((p.z + p.x) * 2.0f) * 0.3f, cos((p.z + p.x) * 1.0f) * 0.3f); + float s1 = length(wlc_mod(p.xy + j0, 2.0f) - 1.0f) - 0.2f; + float s2 = length(wlc_mod(0.5f + p.yz + j1, 2.0f) - 1.0f) - 0.2f; + + return min(w1, min(w2, min(s1, s2))); +} + +static bool wlc_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 waveLatticeCubeFragment( + WaveLatticeCubeVertexOut in [[stage_in]], + constant WaveLatticeCubeUniforms &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 (!wlc_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + float time = uniforms.time * uniforms.travelSpeed; + float3 ro = (roLocal + rdLocal * max(tEntry, 0.0f)) * WLC_SCENE_SCALE; + ro += float3(0.0f, 0.0f, time * 3.8f); + float3 rd = normalize(rdLocal); + rd.xz = wlc_rot(rd.xz, time * 0.23f); + rd = rd.yzx; + rd.xz = wlc_rot(rd.xz, time * 0.20f); + rd = normalize(rd.yzx); + + float t = 0.0f; + float tt = 0.0f; + for (int i = 0; i < WLC_MAX_STEPS; ++i) { + float3 p = ro + rd * t; + tt = wlc_map(p); + if (tt < WLC_HIT_EPS) { + break; + } + t += tt * 0.45f; + if (t > WLC_MAX_DIST) { break; } + } + + float3 p = ro + rd * t; + float3 col = sqrt(max(float3(t * 0.1f), 0.0f)); + float accent = max(0.0f, wlc_map(p - float3(0.1f)) - tt); + float3 color = 0.05f * t + abs(rd) * col + accent; + + color = clamp(color, 0.0f, 1.0f); + return float4(color, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeTypes.swift b/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeTypes.swift new file mode 100644 index 0000000..58e9552 --- /dev/null +++ b/vr-dive/Demos/WaveLatticeCube/WaveLatticeCubeTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct WaveLatticeCubeUniforms in +/// WaveLatticeCubeShaders.metal. +struct WaveLatticeCubeUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/WaveySpheres/WaveySpheresRenderer.swift b/vr-dive/Demos/WaveySpheres/WaveySpheresRenderer.swift new file mode 100644 index 0000000..c94ce03 --- /dev/null +++ b/vr-dive/Demos/WaveySpheres/WaveySpheresRenderer.swift @@ -0,0 +1,170 @@ +import Metal +import simd + +// WaveySpheresRenderer.swift +// +// Source reference requested by user: +// https://www.shadertoy.com/view/WX3cR4 +// This is an original Metal adaptation for vr-dive that reinterprets the +// reference as a 3D raymarched volume inside a 2 meter cube container. + +final class WaveySpheresRenderer: VisualPatternController { + let identifier: VisualPatternKind = .waveySpheres + 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 + + // Mesh vertices span [-1, 1], so a scale of 1.0 yields a 2 meter cube. + private let cubeScale: Float = 1.0 + private let travelSpeed: Float = 0.7 + 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 = WaveySpheresRenderer.makeBox(device: device) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try WaveySpheresRenderer.makePipelineState( + device: device, library: library, maxViewCount: self.maxViewCount) + depthStencilState = WaveySpheresRenderer.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 = WaveySpheresUniforms( + 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 WaveySpheresRenderer { + 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: "waveySpheresVertex") + desc.fragmentFunction = library.makeFunction(name: "waveySpheresFragment") + 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/WaveySpheres/WaveySpheresShaders.metal b/vr-dive/Demos/WaveySpheres/WaveySpheresShaders.metal new file mode 100644 index 0000000..8c6ed12 --- /dev/null +++ b/vr-dive/Demos/WaveySpheres/WaveySpheresShaders.metal @@ -0,0 +1,210 @@ +// WaveySpheresShaders.metal +// +// Source reference requested by user: +// https://www.shadertoy.com/view/WX3cR4 +// This is an original Metal adaptation for vr-dive that preserves the +// wave-driven color spirit of the reference while rebuilding the scene as a +// fully view-independent 3D volume inside a 2 meter cube container. + +#include +using namespace metal; + +#define WS_PI 3.14159265f +// Grid spacing from reference (xz = .3, LAYER_DISTANCE = 5) +#define WS_XZ_SPACING 0.30f +#define WS_LAYER_DIST 5.00f +#define WS_SCENE_SCALE 5.50f +#define WS_MAX_STEPS 72 + +struct WaveySpheresUniforms { + float time; + uint viewCount; + float cubeScale; + float travelSpeed; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct WaveySpheresVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +vertex WaveySpheresVertexOut waveySpheresVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant WaveySpheresUniforms &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; + + WaveySpheresVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// 14-color palette: port from reference +static constant float3 WS_COLS[14] = { + float3(47.0f, 75.0f, 162.0f) / 255.0f, + float3(233.0f, 71.0f, 245.0f) / 255.0f, + float3(128.0f, 63.0f, 224.0f) / 255.0f, + float3(61.0f, 199.0f, 220.0f) / 255.0f, + float3(222.0f, 51.0f, 150.0f) / 255.0f, + float3(160.0f, 220.0f, 70.0f) / 255.0f, + float3(245.0f, 140.0f, 60.0f) / 255.0f, + float3(38.0f, 178.0f, 133.0f) / 255.0f, + float3(220.0f, 50.0f, 50.0f) / 255.0f, + float3(240.0f, 220.0f, 80.0f) / 255.0f, + float3(180.0f, 90.0f, 240.0f) / 255.0f, + float3(80.0f, 210.0f, 255.0f) / 255.0f, + float3(245.0f, 80.0f, 220.0f) / 255.0f, + float3(70.0f, 200.0f, 100.0f) / 255.0f, +}; + +// get_color: port from reference (t in [0, 1]) +static float3 ws_getColor(float t) { + float x = clamp(t, 0.0f, 0.9999f) * 13.0f; + uint i0 = (uint)floor(x); + uint i1 = min(i0 + 1u, 13u); + return mix(WS_COLS[i0], WS_COLS[i1], x - floor(x)); +} + +// hash41: port from reference +static float4 ws_hash41(float p) { + float4 p4 = fract(float4(p) * float4(0.1031f, 0.1030f, 0.0973f, 0.1099f)); + p4 += dot(p4, p4.wzxy + 33.33f); + return fract((p4.xxyz + p4.yzzw) * p4.zywx); +} + +// get_height: exact port from reference +// id = (x, z) grid cell index, layer = y grid cell index, t = iTime +static float ws_getHeight(float2 id, float layer, float t) { + float4 h = ws_hash41(layer) * 1000.0f; + float o = 0.0f; + o += sin((id.x + h.x) * 0.2f + t) * 0.3f; + o += sin((id.y + h.y) * 0.2f + t) * 0.3f; + o += sin((-id.x + id.y + h.z) * 0.3f + t) * 0.3f; + o += sin((id.x + id.y + h.z) * 0.3f + t) * 0.4f; + o += sin((id.x - id.y + h.w) * 0.8f + t) * 0.1f; + return o; +} + +// map: port from reference +// Spheres on a grid: xz spacing = WS_XZ_SPACING, y spacing = WS_LAYER_DIST +// Each sphere's y position is offset by get_height(); radius varies with that offset +static float ws_map(float3 p, float t) { + float3 s = float3(WS_XZ_SPACING, WS_LAYER_DIST, WS_XZ_SPACING); + float3 id = round(p / s); + float ho = ws_getHeight(id.xz, id.y, t); + float3 q = p; + q.y += ho; + q -= s * id; + return length(q) - (smoothstep(1.3f, -1.3f, ho) * 0.03f + 0.0001f); +} + +static bool ws_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 ws_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 ws_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)); +} + +fragment float4 waveySpheresFragment( + WaveySpheresVertexOut in [[stage_in]], + constant WaveySpheresUniforms &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 (!ws_boxHit(roLocal, rdLocal, float3(-1.0f), float3(1.0f), tEntry, tExit)) { + discard_fragment(); + } + + // Cube entry shell (edge + fresnel hint) + float3 entryLocal = roLocal + rdLocal * max(tEntry, 0.0f); + float3 faceNrm = ws_faceNormal(entryLocal); + float fresnel = pow(1.0f - max(0.0f, dot(-rdLocal, faceNrm)), 2.0f); + float edge = smoothstep(0.18f, 0.02f, ws_edgeDistance(entryLocal)); + float3 shellColor = float3(0.04f, 0.06f, 0.09f) + + float3(0.25f, 0.55f, 0.78f) * fresnel * 0.45f + + float3(0.85f, 0.95f, 1.00f) * edge * 0.32f; + + // iTime equivalent + float t = uniforms.time * uniforms.travelSpeed; + + // Phase variables: port of reference's mainImage preamble + float phase = t * 0.2f; + float y = sin(phase); + float ny = smoothstep(-1.0f, 1.0f, y); + + // Single cycling color per frame: port of reference's c = get_color(...) + // One full palette pass every 5 * 2*PI ≈ 31 s + float3 c = ws_getColor(fract(t / (5.0f * 2.0f * WS_PI))); + + // Virtual camera in scene space: port of reference's ro = vec3(0, y*LAYER_DISTANCE*.5, -t) + float3 virtualCam = float3(0.0f, y * WS_LAYER_DIST * 0.5f, -t); + + // Ray origin: cube entry point scaled to scene space + virtual camera offset + // This maps the bounded cube into the reference's infinite flying-camera scene + float3 roScene = entryLocal * WS_SCENE_SCALE + virtualCam; + float3 rdScene = normalize(rdLocal); + float travelLength = (tExit - max(tEntry, 0.0f)) * WS_SCENE_SCALE; + + // Glow accumulation: direct port of reference's main loop + float3 col = float3(0.0f); + float d = 0.0f; + for (int i = 0; i < WS_MAX_STEPS; ++i) { + if (d >= travelLength) break; + float3 p = roScene + rdScene * d; + float dt = ws_map(p, t); + // Step modulation from reference: dt*(cos(ny*PI*2.)*.3+.5) + dt = max(dt * (cos(ny * WS_PI * 2.0f) * 0.3f + 0.5f), 1e-3f); + col += (0.1f / dt) * c; + d += dt * 0.8f; + } + + // Tonemap: port of reference's tanh(col * .01) + col = tanh(col * 0.01f); + + // Overlay subtle cube shell on dark areas + col += shellColor * (1.0f - smoothstep(0.0f, 0.3f, dot(col, float3(0.3f, 0.6f, 0.1f)))) * 0.15f; + + return float4(col, 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/WaveySpheres/WaveySpheresTypes.swift b/vr-dive/Demos/WaveySpheres/WaveySpheresTypes.swift new file mode 100644 index 0000000..96b8127 --- /dev/null +++ b/vr-dive/Demos/WaveySpheres/WaveySpheresTypes.swift @@ -0,0 +1,11 @@ +import simd + +/// Must stay in sync with the Metal struct WaveySpheresUniforms in +/// WaveySpheresShaders.metal. +struct WaveySpheresUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var travelSpeed: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/WeirdSurface/WeirdSurfaceRenderer.swift b/vr-dive/Demos/WeirdSurface/WeirdSurfaceRenderer.swift new file mode 100644 index 0000000..34c71ca --- /dev/null +++ b/vr-dive/Demos/WeirdSurface/WeirdSurfaceRenderer.swift @@ -0,0 +1,176 @@ +import Metal +import simd + +// WeirdSurfaceRenderer.swift +// 3D cube-container adaptation of "Weird Surface" (ShaderToy NXsGzX). +// Original: https://www.shadertoy.com/view/NXsGzX by Noztol + +final class WeirdSurfaceRenderer: VisualPatternController { + let identifier: VisualPatternKind = .weirdSurface + 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 = WeirdSurfaceRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0)) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try WeirdSurfaceRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = WeirdSurfaceRenderer.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 = WeirdSurfaceUniforms( + 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 WeirdSurfaceRenderer { + 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: "weirdSurfaceVertex") + desc.fragmentFunction = library.makeFunction(name: "weirdSurfaceFragment") + 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/WeirdSurface/WeirdSurfaceShaders.metal b/vr-dive/Demos/WeirdSurface/WeirdSurfaceShaders.metal new file mode 100644 index 0000000..2701dd6 --- /dev/null +++ b/vr-dive/Demos/WeirdSurface/WeirdSurfaceShaders.metal @@ -0,0 +1,260 @@ +// WeirdSurfaceShaders.metal +// 3D visionOS adaptation of "Weird Surface" (ShaderToy NXsGzX). +// +// Original GLSL: +// https://www.shadertoy.com/view/NXsGzX +// Klein bottle figure-8 implicit surface by Noztol +// Ported to Metal / visionOS cube-container by the vr-dive project. +// +// Technique: Newton-step ray marching with bisection refinement. +// Multi-layer alpha compositing lets the camera see through the +// translucent surface to inner shells. Auto-rotation replaces mouse. +// +// GLSL → Metal translation notes: +// • GLSL `p.yz *= mat2(c,-s,s,c)` (row-vec × col-major mat2) +// = Metal `p.yz = float2x2(float2(c,-s), float2(s,c)) * p.yz` (col-vec × mat) — same result. +// • `atan(z, x)` two-arg GLSL form → `atan2(z, x)` in Metal. +// • `PI` → `M_PI_F`. +// • Original unrotates the normal to world space for shading; in the cube version +// rd_s is already in the rotated scene space, so the unrotation is omitted. +// • Original scales q_ro/q_rd by 1.4 (zoom trick); sceneScale handles this instead. + +#include +using namespace metal; + +// --------------------------------------------------------------------------- +// Shared types (must match WeirdSurfaceTypes.swift) +// --------------------------------------------------------------------------- + +struct WeirdSurfaceUniforms { + float time; + uint viewCount; + float cubeScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct WeirdSurfaceVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +// --------------------------------------------------------------------------- +// Rotation helper +// GLSL: p.yz *= mat2(c,-s,s,c) (row-vec form) +// Metal: p.yz = wsRot(a) * p.yz (col-vec form — same transformation) +// float2x2 is column-major: col0=float2(c,-s), col1=float2(s,c) +// M*v = (c*v.x + s*v.y, -s*v.x + c*v.y) ✓ +// --------------------------------------------------------------------------- + +static float2x2 wsRot(float a) { + float s = sin(a), c = cos(a); + return float2x2(float2(c, -s), float2(s, c)); +} + +// --------------------------------------------------------------------------- +// Implicit surface — figure-8 Klein bottle equation +// --------------------------------------------------------------------------- + +static float wsMapV(float3 p) { + float x = p.x, y = p.y, z = p.z; + float r2 = x*x + y*y + z*z; + float a = r2 + 2.0f*y - 1.0f; + float b = r2 - 2.0f*y - 1.0f; + return a * (b*b - 8.0f*z*z) + 16.0f*x*z*b; +} + +// --------------------------------------------------------------------------- +// Axis-aligned box intersection; returns (tNear, tFar) +// --------------------------------------------------------------------------- + +static float2 wsBoxIntersect(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)); +} + +// --------------------------------------------------------------------------- +// Vertex +// --------------------------------------------------------------------------- + +vertex WeirdSurfaceVertexOut weirdSurfaceVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant WeirdSurfaceUniforms &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; + + WeirdSurfaceVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +// --------------------------------------------------------------------------- +// Fragment — Newton-step + bisection implicit surface marcher +// --------------------------------------------------------------------------- + +fragment float4 weirdSurfaceFragment( + WeirdSurfaceVertexOut in [[stage_in]], + constant WeirdSurfaceUniforms &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); + + // Camera and surface 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 + bool insideBox = all(abs(eye) < halfExt - 1.0e-3f); + float2 tBox = wsBoxIntersect(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(); } + + // Scene space: cube ±1 → ±3.5 scene units. + // Klein bottle bounding sphere has radius ~3; the cube contains it with margin. + const float sceneScale = 3.5f; + + // Flip z: aligns cube local-space convention with ShaderToy scene +Z forward. + float3 ro_entry = (eye + viewDir * (tStart + 0.001f)); + float3 ro_s = float3(ro_entry.x, ro_entry.y, -ro_entry.z) * sceneScale; + float3 rd_s = float3(viewDir.x, viewDir.y, -viewDir.z); + + // Auto-rotation — mirrors original: t1 = t2 = iTime * 0.3 (no mouse in visionOS) + float rotT = uniforms.time * 0.3f; + + // Rotate scene ray so the bottle spins inside the fixed cube. + // GLSL: q.yz *= rot(t1); q.xz *= rot(t2) + // Metal: q.yz = wsRot(t1) * q.yz; q.xz = wsRot(t2) * q.xz (same transformation) + ro_s.yz = wsRot(rotT) * ro_s.yz; + ro_s.xz = wsRot(rotT) * ro_s.xz; + rd_s.yz = wsRot(rotT) * rd_s.yz; + rd_s.xz = wsRot(rotT) * rd_s.xz; + + // Maximum march distance in scene units (bounded by cube exit) + float t_exit = (tEnd - tStart) * sceneScale; + + float3 col = float3(0.0f); + float alpha = 1.0f; + + float t = 0.0f; + float v_curr = wsMapV(ro_s + rd_s * t); + + // ----------------------------------------------------------------------- + // Newton-step march — 130 iterations matching original + // ----------------------------------------------------------------------- + for (int i = 0; i < 130; i++) { + if (alpha < 0.01f || t > t_exit) break; + + float3 q = ro_s + rd_s * t; + + // Directional derivative estimate along rd_s + float eps = 0.01f; + float v_eps = wsMapV(q + rd_s * eps); + float dirDeriv = abs(v_eps - v_curr) / eps; + if (dirDeriv < 0.0001f) dirDeriv = 1.0f; + + // Newton step distance estimate + float de = abs(v_curr) / dirDeriv; + float dt = clamp(de * 0.5f, 0.01f, 0.4f); + + float t_next = t + dt; + float3 q_next = ro_s + rd_s * t_next; + float v_next = wsMapV(q_next); + + if (v_curr * v_next < 0.0f) { + // Sign change detected — bisection refinement (8 iterations) + float ta = t, tb = t_next; + float va = v_curr; + + for (int b = 0; b < 8; b++) { + float tm = (ta + tb) * 0.5f; + float vm = wsMapV(ro_s + rd_s * tm); + if (va * vm <= 0.0f) { + tb = tm; + } else { + ta = tm; + va = vm; + } + } + + float t_hit = (ta + tb) * 0.5f; + float3 q_hit = ro_s + rd_s * t_hit; + + // Surface normal via central difference (step 0.005) + const float2 e = float2(0.005f, 0.0f); + float3 g_hit = float3( + wsMapV(q_hit + e.xyy) - wsMapV(q_hit - e.xyy), + wsMapV(q_hit + e.yxy) - wsMapV(q_hit - e.yxy), + wsMapV(q_hit + e.yyx) - wsMapV(q_hit - e.yyx)); + float3 n_obj = normalize(g_hit); + + // Normal and rd_s are already in the same (rotated) space — no unrotation needed. + if (dot(n_obj, rd_s) > 0.0f) n_obj = -n_obj; + + // Spherical UV coordinates for grid pattern + float r = length(q_hit); + float theta = acos(clamp(q_hit.y / max(r, 1.0e-5f), -1.0f, 1.0f)) / M_PI_F; + // GLSL atan(z, x) two-arg = Metal atan2(z, x) + float phi = atan2(q_hit.z, q_hit.x) / (2.0f * M_PI_F); + + float g1 = abs(fract(theta * 12.0f) - 0.5f); + float g2 = abs(fract(phi * 24.0f) - 0.5f); + float grid = smoothstep(0.04f, 0.0f, min(g1, g2)); + + float ndotv = clamp(dot(n_obj, -rd_s), 0.0f, 1.0f); + float fresnel = pow(1.0f - ndotv, 3.0f); + + float3 baseCol = float3(0.05f, 0.10f, 0.30f); + float3 gridCol = float3(0.30f, 0.60f, 1.00f); + float3 surfaceCol = mix(baseCol, gridCol, grid) + + fresnel * float3(0.5f, 0.8f, 1.0f); + + float surfaceAlpha = clamp(0.4f + grid * 0.6f + fresnel * 0.3f, 0.0f, 1.0f); + + col += alpha * surfaceAlpha * surfaceCol; + alpha *= (1.0f - surfaceAlpha); + + // Step slightly past surface to continue looking for inner layers + t = t_hit + 0.005f; + v_curr = wsMapV(ro_s + rd_s * t); + } else { + t = t_next; + v_curr = v_next; + } + } + + // Dark background composited with remaining alpha; tone-map with 1-exp + float3 bg = float3(0.02f, 0.03f, 0.06f); + col += alpha * bg; + col = 1.0f - exp(-col * 1.5f); + + return float4(col, 1.0f); +} diff --git a/vr-dive/Demos/WeirdSurface/WeirdSurfaceTypes.swift b/vr-dive/Demos/WeirdSurface/WeirdSurfaceTypes.swift new file mode 100644 index 0000000..7740cb1 --- /dev/null +++ b/vr-dive/Demos/WeirdSurface/WeirdSurfaceTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct WeirdSurfaceUniforms in WeirdSurfaceShaders.metal. +struct WeirdSurfaceUniforms { + var time: Float + var viewCount: UInt32 + var cubeScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Demos/hexwaves/hexwavesRenderer.swift b/vr-dive/Demos/hexwaves/hexwavesRenderer.swift new file mode 100644 index 0000000..8d2bb9b --- /dev/null +++ b/vr-dive/Demos/hexwaves/hexwavesRenderer.swift @@ -0,0 +1,177 @@ +import Metal +import simd + +// hexwavesRenderer.swift +// +// Cube-container adaptation of ShaderToy "hexwaves" (XsBczc) by mattz. +// 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 HexwavesRenderer: VisualPatternController { + let identifier: VisualPatternKind = .hexwaves + 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 = HexwavesRenderer.makeBox( + device: device, + localHalfExtents: SIMD3(repeating: 1.0) * 1.02) + vertexBuffer = geo.vertexBuffer + indexBuffer = geo.indexBuffer + indexCount = geo.indexCount + + pipelineState = try HexwavesRenderer.makePipelineState( + device: device, + library: library, + maxViewCount: self.maxViewCount) + depthStencilState = HexwavesRenderer.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 = HexwavesUniforms( + 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 HexwavesRenderer { + 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: "hexwavesVertex") + desc.fragmentFunction = library.makeFunction(name: "hexwavesFragment") + 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/hexwaves/hexwavesShaders.metal b/vr-dive/Demos/hexwaves/hexwavesShaders.metal new file mode 100644 index 0000000..e660aa0 --- /dev/null +++ b/vr-dive/Demos/hexwaves/hexwavesShaders.metal @@ -0,0 +1,315 @@ +// hexwavesShaders.metal +// Adapted from ShaderToy "hexwaves" by mattz. +// Source: https://www.shadertoy.com/view/XsBczc +// License: Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. +// +// Metal adaptation notes: +// - The original shader used a fixed camera plus a cubemap sampler. +// This version reconstructs the real per-eye world ray, intersects it with a +// 2 m cube container, and starts tracing at the visible cube surface or at +// the eye when the viewer is inside the cube. +// - The hex grid traversal continues beyond the container boundary, so the +// simulated wave field is not clipped to the cube volume. +// - The cubemap reflection is replaced with a procedural environment because +// this project does not bind `iChannel0` for container demos. + +#include +using namespace metal; + +struct HexwavesUniforms { + float time; + uint viewCount; + float boxScale; + float padding; + float4 objectCenter; +}; + +struct MeshVertex { + float3 position; + float3 normal; +}; + +struct HexwavesVertexOut { + float4 clipPos [[position]]; + float3 worldPos; + uint viewIndex [[flat]]; +}; + +static constant float HW_HEX_FACTOR = 0.8660254037844386f; +static constant float3 HW_FOG_COLOR = float3(0.9f, 0.95f, 1.0f); +static constant float3 HW_BOX_HALF = float3(1.0f); +static constant float HW_TRACE_EPSILON = 0.001f; + +vertex HexwavesVertexOut hexwavesVertex( + ushort amplificationID [[amplification_id]], + const device MeshVertex *vertices [[buffer(0)]], + constant HexwavesUniforms &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; + + HexwavesVertexOut out; + out.clipPos = vpMatrices[viewIndex] * float4(worldPos, 1.0f); + out.worldPos = worldPos; + out.viewIndex = viewIndex; + return out; +} + +static float2 hwHexFromCart(float2 p) { + return float2(p.x / HW_HEX_FACTOR, p.y); +} + +static float2 hwCartFromHex(float2 g) { + return float2(g.x * HW_HEX_FACTOR, g.y); +} + +static float hwMod(float x, float y) { + return x - y * floor(x / y); +} + +static float2 hwMod2(float2 x, float2 y) { + return x - y * floor(x / y); +} + +static float2 hwRotate(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 hwRotateScene(float3 p, float time) { + p.xy = hwRotate(p.xy, -0.14f * time); + p.xz = hwRotate(p.xz, -0.35f - 0.2f * cos(0.031513f * time)); + p.yz = hwRotate(p.yz, 0.17f * sin(0.21f * time) + 0.4f); + return p; +} + +static float2 hwFaceUV(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 hwBoxIntersect(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 hexDist(float2 p) { + p = abs(p); + return max(dot(p, float2(HW_HEX_FACTOR, 0.5f)), p.y) - 1.0f; +} + +static float2 nearestHexCell(float2 pos) { + float2 gpos = hwHexFromCart(pos); + float2 hexInt = floor(gpos); + + float sy = step(2.0f, hwMod(hexInt.x + 1.0f, 4.0f)); + hexInt += hwMod2(float2(hexInt.x, hexInt.y + sy), float2(2.0f)); + + float2 gdiff = gpos - hexInt; + if (dot(abs(gdiff), float2(HW_HEX_FACTOR * HW_HEX_FACTOR, 0.5f)) > 1.0f) { + float2 delta = float2(gdiff.x < 0.0f ? -1.0f : 1.0f, gdiff.y < 0.0f ? -1.0f : 1.0f); + hexInt += delta * float2(2.0f, 1.0f); + } + + return hexInt; +} + +static float2 alignNormal(float2 h, float2 d) { + float s = dot(h, hwCartFromHex(d)) < 0.0f ? -1.0f : 1.0f; + return h * s; +} + +static float3 rayHexIntersect(float2 ro, float2 rd, float2 h) { + float2 n = hwCartFromHex(h); + float denom = dot(n, rd); + if (abs(denom) < 1.0e-5f) { + return float3(h, 1.0e20f); + } + + float u = (1.0f - dot(n, ro)) / denom; + return float3(h, u > 0.0f ? u : 1.0e20f); +} + +static float3 rayMin(float3 a, float3 b) { + return a.z < b.z ? a : b; +} + +static float3 hash32(float2 p) { + float3 p3 = fract(float3(p.x, p.y, p.x) * float3(0.1031f, 0.1030f, 0.0973f)); + p3 += dot(p3, p3.yxz + 19.19f); + return fract((p3.xxy + p3.yzz) * p3.zyx); +} + +static float heightForPos(float2 pos, float time) { + pos += float2(2.0f * sin(time * 0.3f + 0.2f), 2.0f * cos(time * 0.1f + 0.5f)); + float x2 = dot(pos, pos); + float x = sqrt(x2); + return 6.0f * cos(x * 0.2f + time) * exp(-x2 / 128.0f); +} + +static float3 hwEnvironment(float3 dir, float time) { + dir = normalize(dir); + float skyMix = clamp(0.5f + 0.5f * dir.z, 0.0f, 1.0f); + float horizon = pow(max(1.0f - abs(dir.z), 0.0f), 3.0f); + float rings = 0.5f + 0.5f * cos(7.0f * atan2(dir.y, dir.x) + 4.0f * dir.z + time * 0.7f); + float3 sky = mix(float3(0.25f, 0.32f, 0.44f), HW_FOG_COLOR, skyMix); + float3 glow = mix(float3(0.15f, 0.08f, 0.22f), float3(0.85f, 0.95f, 1.0f), rings); + return sky + glow * horizon * 0.35f; +} + +static float4 surface(float3 rd, float2 cell, float4 hitNT, float bdist, float time) { + float fogc = exp(-length(hitNT.w * rd) * 0.02f); + + float3 n = hitNT.xyz; + float3 noise = (hash32(cell) - 0.5f) * 0.15f; + n = normalize(n + noise); + + float borderScale = 0.006f * max(1.0f, 0.1f * hitNT.w); + const float borderSize = 0.04f; + float border = smoothstep(0.0f, borderScale * hitNT.w + 1.0e-4f, abs(bdist) - borderSize); + border = mix(border, 0.75f, smoothstep(18.0f, 45.0f, hitNT.w)); + + float3 L = normalize(float3(3.0f, 1.0f, 4.0f)); + float diffamb = clamp(dot(n, L), 0.0f, 1.0f) * 0.8f + 0.2f; + + float3 color = float3(1.0f); + color = mix(float3(0.1f, 0.0f, 0.08f), color, border); + color *= diffamb; + + color = mix(color, hwEnvironment(reflect(rd, n), time), 0.4f * border); + color = mix(HW_FOG_COLOR, color, fogc); + + return float4(color, border); +} + +static float3 shade(float3 ro, float3 rd, float time) { + float3 color = HW_FOG_COLOR; + float2 curCell = nearestHexCell(ro.xy); + + float2 h0 = alignNormal(float2(0.0f, 1.0f), rd.xy); + float2 h1 = alignNormal(float2(1.0f, 0.5f), rd.xy); + float2 h2 = alignNormal(float2(1.0f, -0.5f), rd.xy); + + float cellHeight = heightForPos(hwCartFromHex(curCell), time); + float alpha = 1.0f; + + for (int i = 0; i < 80; ++i) { + bool hit = false; + float4 hitNT = float4(0.0f); + float bdist = 1.0e5f; + + float2 curCenter = hwCartFromHex(curCell); + float2 rdelta = ro.xy - curCenter; + + float3 ht = rayHexIntersect(rdelta, rd.xy, h0); + ht = rayMin(ht, rayHexIntersect(rdelta, rd.xy, h1)); + ht = rayMin(ht, rayHexIntersect(rdelta, rd.xy, h2)); + + float tz = 1.0e20f; + if (abs(rd.z) > 1.0e-5f) { + tz = (cellHeight - ro.z) / rd.z; + } + + if (ro.z > cellHeight && rd.z < 0.0f && tz > 0.0f && tz < ht.z) { + hit = true; + hitNT = float4(0.0f, 0.0f, 1.0f, tz); + float2 pinter = ro.xy + rd.xy * tz; + bdist = hexDist(pinter - curCenter); + } else { + curCell += 2.0f * ht.xy; + + float2 n = hwCartFromHex(ht.xy); + curCenter = hwCartFromHex(curCell); + + float prevCellHeight = cellHeight; + cellHeight = heightForPos(curCenter, time); + + float3 pIntersect = ro + rd * ht.z; + if (pIntersect.z < cellHeight) { + hitNT = float4(n, 0.0f, ht.z); + hit = true; + + bdist = cellHeight - pIntersect.z; + bdist = min(bdist, pIntersect.z - prevCellHeight); + + float2 p = pIntersect.xy - curCenter; + p -= n * dot(p, n); + bdist = min(bdist, abs(length(p) - 0.5f / HW_HEX_FACTOR)); + } + } + + if (hit) { + float4 hitColor = surface(rd, curCell, hitNT, bdist, time); + color = mix(color, hitColor.xyz, alpha); + alpha *= 0.17f * hitColor.w; + + ro = ro + rd * hitNT.w; + rd = reflect(rd, hitNT.xyz); + ro += 1.0e-3f * hitNT.xyz; + + h0 = alignNormal(float2(0.0f, 1.0f), rd.xy); + h1 = alignNormal(float2(1.0f, 0.5f), rd.xy); + h2 = alignNormal(float2(1.0f, -0.5f), rd.xy); + } + + if (alpha < 0.01f) { + break; + } + } + + color = mix(color, HW_FOG_COLOR, alpha); + return color; +} + +fragment float4 hexwavesFragment( + HexwavesVertexOut in [[stage_in]], + constant HexwavesUniforms &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) < HW_BOX_HALF - 1.0e-3f); + float2 tBox = hwBoxIntersect(eye, rd, HW_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 + HW_TRACE_EPSILON); + + float3 sceneRo = hwRotateScene(marchOrigin * 8.0f + float3(0.0f, 0.0f, 2.0f), uniforms.time); + float3 sceneRd = normalize(hwRotateScene(rd, uniforms.time)); + + float3 color = shade(sceneRo, sceneRd, uniforms.time); + color = sqrt(max(color, 0.0f)); + + float2 q = hwFaceUV(hit); + color *= pow(max(16.0f * q.x * q.y * (1.0f - q.x) * (1.0f - q.y), 0.0f), 0.1f); + return float4(clamp(color, 0.0f, 1.0f), 1.0f); +} \ No newline at end of file diff --git a/vr-dive/Demos/hexwaves/hexwavesTypes.swift b/vr-dive/Demos/hexwaves/hexwavesTypes.swift new file mode 100644 index 0000000..404345b --- /dev/null +++ b/vr-dive/Demos/hexwaves/hexwavesTypes.swift @@ -0,0 +1,10 @@ +import simd + +/// Must stay in sync with the Metal struct HexwavesUniforms in hexwavesShaders.metal. +struct HexwavesUniforms { + var time: Float + var viewCount: UInt32 + var boxScale: Float + var padding: Float + var objectCenter: SIMD4 +} diff --git a/vr-dive/Game/GameManager.swift b/vr-dive/Game/GameManager.swift index 26bbba7..b327997 100644 --- a/vr-dive/Game/GameManager.swift +++ b/vr-dive/Game/GameManager.swift @@ -18,6 +18,7 @@ class GameManager { var dpadDown: Bool = false var dpadLeft: Bool = false var dpadRight: Bool = false + var rightShoulder: Bool = false } private let controllerQueue = DispatchQueue(label: "vr-dive.controller.state") @@ -26,7 +27,8 @@ class GameManager { private let movementSpeed: Float = 1.2 private let yawSpeed: Float = .pi / 2.0 - private let deadZone: Float = 0.12 + private let deadZone: Float = 0.2 + private let residualStickClamp: Float = 0.025 private let boostMovementMultiplier: Float = 5.0 private let boostYawMultiplier: Float = 2.0 @@ -53,6 +55,7 @@ class GameManager { var buttonTriangle: Bool // △ = 向上移动 var buttonSquare: Bool // □ = 切换方块类型 var buttonCircle: Bool // ○ = 随机旋转朝向 + var buttonR1: Bool // R1 = 加速前进 } func getTetrisInput() -> TetrisInput { @@ -65,7 +68,8 @@ class GameManager { buttonCross: controllerState.buttonA, // PS5 × maps to buttonA buttonTriangle: controllerState.buttonY, // PS5 △ maps to buttonY buttonSquare: controllerState.buttonX, // PS5 □ maps to buttonX - buttonCircle: controllerState.buttonB // PS5 ○ maps to buttonB + buttonCircle: controllerState.buttonB, // PS5 ○ maps to buttonB + buttonR1: controllerState.rightShoulder ) } } @@ -115,6 +119,7 @@ class GameManager { let buttonX = gamepad.buttonX.isPressed let buttonY = gamepad.buttonY.isPressed let boostActive = gamepad.leftShoulder.isPressed || gamepad.rightShoulder.isPressed + let rightShoulder = gamepad.rightShoulder.isPressed // D-pad let dpadUp = gamepad.dpad.up.isPressed @@ -134,6 +139,7 @@ class GameManager { controllerState.dpadDown = dpadDown controllerState.dpadLeft = dpadLeft controllerState.dpadRight = dpadRight + controllerState.rightShoulder = rightShoulder } logInputEvent( @@ -217,7 +223,14 @@ class GameManager { let magnitude = simd_length(input) guard magnitude > deadZone else { return .zero } let scaled = (magnitude - deadZone) / (1 - deadZone) - return (input / max(magnitude, 0.0001)) * scaled + var filtered = (input / max(magnitude, 0.0001)) * scaled + if abs(filtered.x) < residualStickClamp { + filtered.x = 0 + } + if abs(filtered.y) < residualStickClamp { + filtered.y = 0 + } + return filtered } private func wrapAngle(_ angle: Float) -> Float { diff --git a/vr-dive/Renderer/MeshGeometryFactory.swift b/vr-dive/Renderer/MeshGeometryFactory.swift index 21c8ff5..057f973 100644 --- a/vr-dive/Renderer/MeshGeometryFactory.swift +++ b/vr-dive/Renderer/MeshGeometryFactory.swift @@ -2,6 +2,72 @@ import Metal import simd struct MeshGeometryFactory { + static func makeIcosahedron( + device: MTLDevice, + size: Float = 0.03 + ) -> (vertexBuffer: MTLBuffer, indexBuffer: MTLBuffer, indexCount: Int) { + let phi: Float = 1.618_034 + let rawRadius = sqrt(1 + phi * phi) + let scale = size / rawRadius + + let positions: [SIMD3] = [ + SIMD3(-1, phi, 0), + SIMD3(1, phi, 0), + SIMD3(-1, -phi, 0), + SIMD3(1, -phi, 0), + SIMD3(0, -1, phi), + SIMD3(0, 1, phi), + SIMD3(0, -1, -phi), + SIMD3(0, 1, -phi), + SIMD3(phi, 0, -1), + SIMD3(phi, 0, 1), + SIMD3(-phi, 0, -1), + SIMD3(-phi, 0, 1), + ].map { $0 * scale } + + let vertices: [MeshVertex] = positions.map { position in + let normal = simd_normalize(position) + return MeshVertex(position: position, normal: normal) + } + + let indices: [UInt16] = [ + 0, 11, 5, + 0, 5, 1, + 0, 1, 7, + 0, 7, 10, + 0, 10, 11, + 1, 5, 9, + 5, 11, 4, + 11, 10, 2, + 10, 7, 6, + 7, 1, 8, + 3, 9, 4, + 3, 4, 2, + 3, 2, 6, + 3, 6, 8, + 3, 8, 9, + 4, 9, 5, + 2, 4, 11, + 6, 2, 10, + 8, 6, 7, + 9, 8, 1, + ] + + 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) + } + static func makeOctahedron( device: MTLDevice, size: Float = 0.03 diff --git a/vr-dive/Renderer/PatternSelection.swift b/vr-dive/Renderer/PatternSelection.swift index 1bd6408..6c41c1a 100644 --- a/vr-dive/Renderer/PatternSelection.swift +++ b/vr-dive/Renderer/PatternSelection.swift @@ -4,21 +4,137 @@ import Observation enum VisualPatternKind: String, CaseIterable, Identifiable { case pongWar case cubeField + case fiveCellProjection + case eightCellProjection + case sixteenCellProjection + case twentyFourCellProjection + case oneHundredTwentyCellProjection + case sixHundredCellProjection case lorenzAttractor case fourWingAttractor case aizawaAttractor case julia3D case pagoda case tetris3D + case snake3D + case rhombicDodecahedron + case quatPolynomial + case huashan + case rayMarchingDemo + case cubeRayMarchDemo + case metaball + case synthwaveSunset + case tunnel + case interferenceCascadeCube + case cubicSpaceDivision + case voxelEdges + case pathTilesCube + case gyroidEchoCube + case waveLatticeCube + case waveySpheres + case glowingMountainLines + case magnetar + case spiraledLayers + case angleFire + case orbitalSphereCube + case apollonianIIv4 + case platonicMirror + case glassBox + case cartoonFractalCube + case fractalFlythrough + case hyperbolicGroupLimitSet + case apolloSpiral + case voxelTunnel + case nearLoxodrome + case shield + case digitalLines + case cloudyCrystal + case shaderdoughFairy + case crystalCubeLatticinioCore1 + case fireTornado + case reflectiveWythoffPolyhedra + case apollonian + case magneticLinesThatDrawInGold + case lanterns + case laceTunnel + case torusFan + case apollonianElevator + case torusKnotInR4 + case threeDFire + case bubbleRings + case ether + case fiberSpiral + case saturdayTorus + case tesseractCornerFractal + case hexwaves + case milosRose + case recursiveLotus + case blueFlower + case flowerTest + case floreus + case sineBud + case saturdayWeirdness + case soulstone + case boxOfStars + case mirrorLooping + case greatDodecaheadroll + case playingMarble + case novaMarble + case dirtBall + case fractal49Gaz + case starryPlanes + case fractal77Gaz + case poincareBallHoneycomb + case anotherMarble + case marbleMovingRemix + case slicesInMarbles + case logSphericalKIFSZoomer + case petalsFractal + case goldenApollian + case tunnelingThroughApollianFrac + case fractalCity + case starTrails + case particleRain + case apollonianTwist + case steampunkOrb + case apollonianWires + case kuKo + case sonicAndTails + case followYourLight + case weirdSurface + case neonShells var id: String { rawValue } + var supportsOriginCellInspection: Bool { + switch self { + case .fiveCellProjection, .eightCellProjection, .sixteenCellProjection, + .twentyFourCellProjection, + .oneHundredTwentyCellProjection, .sixHundredCellProjection: + return true + default: + return false + } + } + var displayName: String { switch self { case .pongWar: return "PongWar" case .cubeField: - return "立方体" + return "杂物空间" + case .fiveCellProjection: + return "正五胞体投影" + case .eightCellProjection: + return "正八胞体投影" + case .sixteenCellProjection: + return "正十六胞体投影" + case .twentyFourCellProjection: + return "正二十四胞体投影" + case .oneHundredTwentyCellProjection: + return "正一百二十胞体投影" + case .sixHundredCellProjection: + return "正六百胞体投影" case .lorenzAttractor: return "Lorenz 吸引子" case .fourWingAttractor: @@ -31,16 +147,229 @@ enum VisualPatternKind: String, CaseIterable, Identifiable { return "大雁塔" case .tetris3D: return "3D 俄罗斯方块" + case .snake3D: + return "3D 贪食蛇" + case .rhombicDodecahedron: + return "菱形十二面体镜室" + case .metaball: + return "圆球水滴" + case .quatPolynomial: + return "四元数多项式根" + case .glassBox: + return "玻璃魔方" + case .platonicMirror: + return "反射多面体" + case .synthwaveSunset: + return "合成波日落" + case .tunnel: + return "Tunnel" + case .cubicSpaceDivision: + return "Cubic Space Division" + case .voxelEdges: + return "Voxel Edges" + case .pathTilesCube: + return "PathTilesCube" + case .cartoonFractalCube: + return "CartoonFractalCube" + case .gyroidEchoCube: + return "GyroidEchoCube" + case .waveLatticeCube: + return "WaveLatticeCube" + case .waveySpheres: + return "Wavey spheres" + case .fractalFlythrough: + return "Fractal Flythrough" + case .apollonianIIv4: + return "Apollonian-II-v4" + case .magnetar: + return "Magnetar" + case .spiraledLayers: + return "Spiraled Layers" + case .angleFire: + return "Angle Fire" + case .glowingMountainLines: + return "Glowing Mountain Lines" + case .interferenceCascadeCube: + return "InterferenceCascadeCube" + case .orbitalSphereCube: + return "OrbitalSphereCube" + case .huashan: + return "华山3D扫描" + case .rayMarchingDemo: + return "Ray Marching 演示" + case .cubeRayMarchDemo: + return "方块内 Ray Marching" + case .hyperbolicGroupLimitSet: + return "Hyperbolic Group Limit Set" + case .apolloSpiral: + return "Apollo Spiral" + case .voxelTunnel: + return "Voxel tunnel" + case .nearLoxodrome: + return "Near Loxodrome" + case .shield: + return "Shield" + case .digitalLines: + return "Digital Lines" + case .cloudyCrystal: + return "Cloudy Crystal" + case .shaderdoughFairy: + return "Shaderdough Fairy" + case .crystalCubeLatticinioCore1: + return "Crystal Cube Latticinio core 1" + case .fireTornado: + return "Fire Tornado" + case .reflectiveWythoffPolyhedra: + return "Reflective Wythoff polyhedra" + case .apollonian: + return "apollonian" + case .apollonianTwist: + return "Apollonian Twist" + case .steampunkOrb: + return "Steampunk Orb" + case .apollonianWires: + return "Apollonian Wires" + case .kuKo: + return "KuKo" + case .sonicAndTails: + return "Sonic & Tails" + case .followYourLight: + return "Follow Your Light" + case .weirdSurface: + return "Weird Surface" + case .neonShells: + return "Neon Shells" + case .magneticLinesThatDrawInGold: + return "Magnetic lines that draw in gold" + case .lanterns: + return "Lanterns" + case .laceTunnel: + return "Lace Tunnel" + case .torusFan: + return "Torus fan" + case .apollonianElevator: + return "Apollonian Elevator" + case .torusKnotInR4: + return "Torus Knot in ℝ⁴" + case .threeDFire: + return "3D Fire" + case .bubbleRings: + return "Bubble rings" + case .ether: + return "Ether" + case .fiberSpiral: + return "Fiber Spiral" + case .saturdayTorus: + return "Saturday Torus" + case .tesseractCornerFractal: + return "Tesseract Corner Fractal" + case .hexwaves: + return "hexwaves" + case .milosRose: + return "Milo's Rose" + case .recursiveLotus: + return "Recursive Lotus" + case .blueFlower: + return "Blue Flower" + case .flowerTest: + return "Flower Test" + case .floreus: + return "Floreus" + case .sineBud: + return "Sine bud" + case .saturdayWeirdness: + return "Saturday weirdness" + case .soulstone: + return "Soulstone" + case .boxOfStars: + return "Box of Stars" + case .mirrorLooping: + return "Mirror Looping" + case .greatDodecaheadroll: + return "Great Dodecaheadroll" + case .playingMarble: + return "Playing marble" + case .novaMarble: + return "Nova Marble" + case .dirtBall: + return "Dirt Ball" + case .fractal49Gaz: + return "Fractal 49_gaz" + case .starryPlanes: + return "Starry planes" + case .fractal77Gaz: + return "Fractal 77_gaz" + case .poincareBallHoneycomb: + return "Poincare Ball Honeycomb" + case .anotherMarble: + return "Another Marble" + case .marbleMovingRemix: + return "marble moving remix" + case .slicesInMarbles: + return "slices in marbles" + case .logSphericalKIFSZoomer: + return "Log Spherical KIFS Zoomer" + case .petalsFractal: + return "Petals Fractal" + case .goldenApollian: + return "Golden apollian" + case .tunnelingThroughApollianFrac: + return "Tunneling through apollian frac" + case .fractalCity: + return "Fractal city" + case .starTrails: + return "星轨延时" + case .particleRain: + return "流光雨" + } + } +} + +enum RayMarchingProbeDimTarget: Int, CaseIterable { + case none + case sphere + case torus + + var buttonTitle: String { + switch self { + case .none: + return "灰化: 无" + case .sphere: + return "灰化: 球" + case .torus: + return "灰化: 圆环" + } + } + + func next() -> RayMarchingProbeDimTarget { + switch self { + case .none: + return .sphere + case .sphere: + return .torus + case .torus: + return .none } } } final class PatternCoordinator { + static let minHuashanSampleRatio: Float = 0.05 + static let maxHuashanSampleRatio: Float = 1.0 + static let defaultHuashanSampleRatio: Float = 0.45 + + static func clampedHuashanSampleRatio(_ ratio: Float) -> Float { + min(max(ratio, minHuashanSampleRatio), maxHuashanSampleRatio) + } + private let queue = DispatchQueue(label: "vr-dive.pattern.coordinator", attributes: .concurrent) - private var _current: VisualPatternKind = .tetris3D + private var _current: VisualPatternKind = .neonShells private var _isPaused: Bool = false private var _shouldReset: Bool = false private var _speedMultiplier: Float = 1.0 + private var _originCellInspectionEnabled: Bool = false + private var _rayMarchingProbeDimTarget: RayMarchingProbeDimTarget = .none + private var _huashanSampleRatio: Float = PatternCoordinator.defaultHuashanSampleRatio func currentPattern() -> VisualPatternKind { queue.sync { _current } @@ -77,6 +406,31 @@ final class PatternCoordinator { func setSpeedMultiplier(_ multiplier: Float) { queue.async(flags: .barrier) { self._speedMultiplier = multiplier } } + + func originCellInspectionEnabled() -> Bool { + queue.sync { _originCellInspectionEnabled } + } + + func setOriginCellInspectionEnabled(_ enabled: Bool) { + queue.async(flags: .barrier) { self._originCellInspectionEnabled = enabled } + } + + func rayMarchingProbeDimTarget() -> RayMarchingProbeDimTarget { + queue.sync { _rayMarchingProbeDimTarget } + } + + func setRayMarchingProbeDimTarget(_ target: RayMarchingProbeDimTarget) { + queue.async(flags: .barrier) { self._rayMarchingProbeDimTarget = target } + } + + func huashanSampleRatio() -> Float { + queue.sync { _huashanSampleRatio } + } + + func setHuashanSampleRatio(_ ratio: Float) { + let clamped = Self.clampedHuashanSampleRatio(ratio) + queue.async(flags: .barrier) { self._huashanSampleRatio = clamped } + } } @MainActor @@ -84,6 +438,9 @@ final class PatternCoordinator { final class PatternMenuModel { var selectedPattern: VisualPatternKind { didSet { + if !selectedPattern.supportsOriginCellInspection && originCellInspectionEnabled { + originCellInspectionEnabled = false + } coordinator.setPattern(selectedPattern) } } @@ -100,17 +457,44 @@ final class PatternMenuModel { } } + var originCellInspectionEnabled: Bool = false { + didSet { + coordinator.setOriginCellInspectionEnabled(originCellInspectionEnabled) + if originCellInspectionEnabled { + isPaused = true + } + } + } + + var rayMarchingProbeDimTarget: RayMarchingProbeDimTarget = .none { + didSet { + coordinator.setRayMarchingProbeDimTarget(rayMarchingProbeDimTarget) + } + } + + var huashanSampleRatio: Float = PatternCoordinator.defaultHuashanSampleRatio { + didSet { + coordinator.setHuashanSampleRatio(huashanSampleRatio) + } + } + private let coordinator: PatternCoordinator init(coordinator: PatternCoordinator) { self.coordinator = coordinator self.selectedPattern = coordinator.currentPattern() self.isPaused = coordinator.isPaused() + self.originCellInspectionEnabled = coordinator.originCellInspectionEnabled() + self.rayMarchingProbeDimTarget = coordinator.rayMarchingProbeDimTarget() + self.huashanSampleRatio = coordinator.huashanSampleRatio() } func refreshFromCoordinator() { selectedPattern = coordinator.currentPattern() isPaused = coordinator.isPaused() + originCellInspectionEnabled = coordinator.originCellInspectionEnabled() + rayMarchingProbeDimTarget = coordinator.rayMarchingProbeDimTarget() + huashanSampleRatio = coordinator.huashanSampleRatio() } func reset() { @@ -118,6 +502,18 @@ final class PatternMenuModel { } func toggleSpeed() { - speedMultiplier = speedMultiplier > 1.0 ? 1.0 : 8.0 + speedMultiplier = speedMultiplier > 1.0 ? 1.0 : 5.0 + } + + func cycleRayMarchingProbeDimTarget() { + rayMarchingProbeDimTarget = rayMarchingProbeDimTarget.next() + } + + func adjustHuashanSampleRatio(by delta: Float) { + huashanSampleRatio = PatternCoordinator.clampedHuashanSampleRatio(huashanSampleRatio + delta) + } + + var huashanSampleRatioPercentText: String { + "\(Int((huashanSampleRatio * 100).rounded()))%" } } diff --git a/vr-dive/Renderer/Renderer.swift b/vr-dive/Renderer/Renderer.swift index b9ca919..03f7980 100644 --- a/vr-dive/Renderer/Renderer.swift +++ b/vr-dive/Renderer/Renderer.swift @@ -13,9 +13,7 @@ struct VRConfiguration: CompositorLayerConfiguration { configuration.isFoveationEnabled = supportsFoveation let layoutOptions: LayerRenderer.Capabilities.SupportedLayoutsOptions = - supportsFoveation - ? [.foveationEnabled] - : [] + supportsFoveation ? [.foveationEnabled] : [] let supportedLayouts = capabilities.supportedLayouts(options: layoutOptions) if supportedLayouts.contains(.layered) { configuration.layout = .layered @@ -33,14 +31,18 @@ struct VRConfiguration: CompositorLayerConfiguration { } class Renderer { + private typealias PatternControllerBuilder = () -> VisualPatternController? + let layerRenderer: LayerRenderer let device: MTLDevice + let library: MTLLibrary let commandQueue: MTLCommandQueue let arSession: ARKitSession let worldTracking: WorldTrackingProvider let gameManager: GameManager let patternCoordinator: PatternCoordinator private var patternControllers: [VisualPatternKind: VisualPatternController] = [:] + private var deferredPatternBuilders: [VisualPatternKind: PatternControllerBuilder] = [:] private var activePatternKind: VisualPatternKind private let maxViewCount: Int private var lastKnownDeviceAnchor: ARKit.DeviceAnchor? @@ -59,14 +61,14 @@ class Renderer { init(_ layerRenderer: LayerRenderer, patternCoordinator: PatternCoordinator) { self.layerRenderer = layerRenderer self.device = layerRenderer.device + self.library = device.makeDefaultLibrary()! self.commandQueue = self.device.makeCommandQueue()! self.patternCoordinator = patternCoordinator self.maxViewCount = max(1, layerRenderer.properties.viewCount) - let library = device.makeDefaultLibrary()! - var controllers = Renderer.makePatternControllers( + let patternSetup = Renderer.makePatternControllers( device: device, - library: library, + library: self.library, cubeCount: Renderer.cubeObjectCount, lorenzCount: Renderer.lorenzParticleCount, fourWingCount: Renderer.fourWingParticleCount, @@ -74,6 +76,8 @@ class Renderer { julia3DCount: Renderer.julia3DParticleCount, maxViewCount: maxViewCount ) + var controllers = patternSetup.controllers + self.deferredPatternBuilders = patternSetup.deferredBuilders self.arSession = ARKitSession() self.worldTracking = WorldTrackingProvider() @@ -83,18 +87,30 @@ class Renderer { Renderer.addTetris3D( to: &controllers, device: device, - library: library, + library: self.library, + maxViewCount: maxViewCount, + gameManager: self.gameManager + ) + + // Add Snake3D + Renderer.addSnake3D( + to: &controllers, + device: device, + library: self.library, maxViewCount: maxViewCount, gameManager: self.gameManager ) self.patternControllers = controllers let requestedPattern = patternCoordinator.currentPattern() - if controllers[requestedPattern] != nil { + if controllers[requestedPattern] != nil || deferredPatternBuilders[requestedPattern] != nil { self.activePatternKind = requestedPattern } else if let fallback = controllers.keys.first { self.activePatternKind = fallback patternCoordinator.setPattern(fallback) + } else if let deferredFallback = deferredPatternBuilders.keys.first { + self.activePatternKind = deferredFallback + patternCoordinator.setPattern(deferredFallback) } else { fatalError("No render patterns available") } @@ -102,6 +118,12 @@ class Renderer { func startRenderLoop() { print("[Renderer] Starting render loop...") + + // Pre-warm GPU pipelines for all registered pattern controllers. + // This triggers Metal's JIT shader compilation before the first rendered frame, + // preventing compositor watchdog timeouts caused by slow first-frame compilation. + warmupPipelines() + Task { do { try await arSession.run([worldTracking]) @@ -119,9 +141,44 @@ class Renderer { print("[Renderer] Render thread started") } + /// Submits a minimal 1×1 offscreen render pass for every registered pattern controller + /// so that Metal compiles their pipelines before the compositor needs the first real frame. + private func warmupPipelines() { + let colorDesc = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba16Float, width: 1, height: 1, mipmapped: false) + colorDesc.usage = [.renderTarget] + colorDesc.storageMode = .private + guard let colorTex = device.makeTexture(descriptor: colorDesc) else { return } + + let depthDesc = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .depth32Float, width: 1, height: 1, mipmapped: false) + depthDesc.usage = [.renderTarget] + depthDesc.storageMode = .private + guard let depthTex = device.makeTexture(descriptor: depthDesc) else { return } + + let passDesc = MTLRenderPassDescriptor() + passDesc.colorAttachments[0].texture = colorTex + passDesc.colorAttachments[0].loadAction = .clear + passDesc.colorAttachments[0].storeAction = .dontCare + passDesc.depthAttachment.texture = depthTex + passDesc.depthAttachment.loadAction = .clear + passDesc.depthAttachment.clearDepth = 0.0 + passDesc.depthAttachment.storeAction = .dontCare + + guard let cmdBuf = commandQueue.makeCommandBuffer(), + let enc = cmdBuf.makeRenderCommandEncoder(descriptor: passDesc) + else { return } + enc.endEncoding() + cmdBuf.commit() + cmdBuf.waitUntilCompleted() + print("[Renderer] Pipeline warmup complete") + } + func renderLoop() { print("[Renderer] Render loop started, layerRenderer state: \(layerRenderer.state)") var frameCount = 0 + var didLogFirstFrame = false + var lastMissingAnchorLogTime: CFTimeInterval = 0 while true { if layerRenderer.state == .invalidated { print("[Renderer] Layer renderer invalidated, exiting") @@ -144,9 +201,10 @@ class Renderer { continue } - if frameCount == 0 { + if !didLogFirstFrame { print("[Renderer] First frame rendering...") - } else if frameCount % 60 == 0 { + didLogFirstFrame = true + } else if frameCount > 0 && frameCount % 60 == 0 { print("[Renderer] Frame \(frameCount) rendered") } @@ -158,7 +216,7 @@ class Renderer { Thread.sleep(forTimeInterval: 0.001) continue } - + // Double-check state after getting frame but before processing // This catches the transition that happens between queryNextFrame and startUpdate guard layerRenderer.state == .running else { @@ -168,6 +226,9 @@ class Renderer { var shouldSkipFrame = false autoreleasepool { + var shouldEndUpdate = false + var didStartSubmission = false + // Final state check before any frame operations guard layerRenderer.state == .running else { shouldSkipFrame = true @@ -175,21 +236,31 @@ class Renderer { } frame.startUpdate() - + shouldEndUpdate = true + // Check state immediately after startUpdate - if transitioning, end gracefully guard layerRenderer.state == .running else { - frame.endUpdate() + if shouldEndUpdate { + frame.endUpdate() + } shouldSkipFrame = true return } let animationTime = Float(Date().timeIntervalSince(startTime)) let predictedTiming = frame.predictTiming() + guard predictedTiming != nil else { + // Frame is no longer valid; do not call endUpdate on an invalid frame. + shouldEndUpdate = false + shouldSkipFrame = true + return + } let presentationTimestamp = presentationTimeInterval(from: predictedTiming) let drawables = frame.queryDrawables() guard !drawables.isEmpty else { - frame.endUpdate() + // queryDrawables can invalidate the frame during immersive dismissal. + shouldEndUpdate = false shouldSkipFrame = true return } @@ -210,40 +281,66 @@ class Renderer { deviceAnchorTransform: anchor.originFromAnchorTransform, currentTime: animationTime ) - } else if frameCount % 120 == 0 { + } else if CFAbsoluteTimeGetCurrent() - lastMissingAnchorLogTime >= 1.0 { + lastMissingAnchorLogTime = CFAbsoluteTimeGetCurrent() print("[Renderer] Waiting for reliable world tracking data...") } let pattern = resolveActivePatternController() + let isPaused = patternCoordinator.isPaused() + let simulationContext = PatternSimulationContext( + commandQueue: commandQueue, + time: animationTime, + speedMultiplier: patternCoordinator.speedMultiplier(), + isPaused: isPaused, + originCellInspectionEnabled: patternCoordinator.originCellInspectionEnabled(), + rayMarchingProbeDimTarget: patternCoordinator.rayMarchingProbeDimTarget(), + huashanSampleRatio: patternCoordinator.huashanSampleRatio() + ) + pattern?.synchronizeState(simulationContext) if patternCoordinator.shouldReset() { pattern?.resetToInitialState() patternCoordinator.clearResetFlag() } - if let activePattern = pattern, !patternCoordinator.isPaused() { - let simulationContext = PatternSimulationContext( - commandQueue: commandQueue, - time: animationTime, - speedMultiplier: patternCoordinator.speedMultiplier() - ) + if let activePattern = pattern, !isPaused { activePattern.updateSimulation(simulationContext) } + guard let validAnchor = anchorToUse else { + if shouldEndUpdate { + frame.endUpdate() + shouldEndUpdate = false + } + completeEmptySubmissionIfPossible(for: frame, drawables: drawables) + shouldSkipFrame = true + return + } + var pendingCommands: [(LayerRenderer.Drawable, MTLCommandBuffer)] = [] for drawable in drawables { if let commandBuffer = encodeDrawable( drawable: drawable, pattern: pattern, - deviceAnchor: anchorToUse, + deviceAnchor: validAnchor, time: animationTime ) { pendingCommands.append((drawable, commandBuffer)) } } - frame.endUpdate() - + if shouldEndUpdate { + frame.endUpdate() + shouldEndUpdate = false + } + + guard !pendingCommands.isEmpty else { + completeEmptySubmissionIfPossible(for: frame, drawables: drawables) + shouldSkipFrame = true + return + } + // Check state before submission - if not running, skip submission entirely guard layerRenderer.state == .running else { shouldSkipFrame = true @@ -251,33 +348,26 @@ class Renderer { } frame.startSubmission() - + didStartSubmission = true + // Check state after startSubmission - if transitioning, end submission gracefully guard layerRenderer.state == .running else { - frame.endSubmission() + if didStartSubmission { + frame.endSubmission() + } shouldSkipFrame = true return } - // If we have valid commands, submit them - if !pendingCommands.isEmpty, let validAnchor = anchorToUse { - for (drawable, commandBuffer) in pendingCommands { - drawable.deviceAnchor = validAnchor - drawable.encodePresent(commandBuffer: commandBuffer) - commandBuffer.commit() - } - } else { - // No valid commands - create minimal submission for each drawable - for drawable in drawables { - if let commandBuffer = commandQueue.makeCommandBuffer() { - drawable.encodePresent(commandBuffer: commandBuffer) - commandBuffer.commit() - } - } - shouldSkipFrame = true + for (drawable, commandBuffer) in pendingCommands { + drawable.deviceAnchor = validAnchor + drawable.encodePresent(commandBuffer: commandBuffer) + commandBuffer.commit() } - frame.endSubmission() + if didStartSubmission { + frame.endSubmission() + } } if shouldSkipFrame { @@ -297,15 +387,35 @@ class Renderer { return seconds + attoseconds } + 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() + } + private func resolveActivePatternController() -> VisualPatternController? { let desiredPattern = patternCoordinator.currentPattern() - if desiredPattern != activePatternKind, let controller = patternControllers[desiredPattern] { - activePatternKind = desiredPattern - print("[Renderer] Switching to pattern: \(desiredPattern.rawValue)") - return controller + if desiredPattern != activePatternKind { + if let controller = patternControllers[desiredPattern] + ?? loadDeferredPatternController(for: desiredPattern) + { + activePatternKind = desiredPattern + print("[Renderer] Switching to pattern: \(desiredPattern.rawValue)") + return controller + } } - if let controller = patternControllers[activePatternKind] { + if let controller = patternControllers[activePatternKind] + ?? loadDeferredPatternController(for: activePatternKind) + { return controller } @@ -314,9 +424,33 @@ class Renderer { return fallback } + if let deferredFallback = deferredPatternBuilders.keys.first, + let controller = loadDeferredPatternController(for: deferredFallback) + { + activePatternKind = deferredFallback + return controller + } + return nil } + private func loadDeferredPatternController(for kind: VisualPatternKind) + -> VisualPatternController? + { + if let existing = patternControllers[kind] { + return existing + } + + guard let builder = deferredPatternBuilders.removeValue(forKey: kind), + let controller = builder() + else { + return nil + } + + patternControllers[kind] = controller + return controller + } + private func encodeDrawable( drawable: LayerRenderer.Drawable, pattern: VisualPatternController?, @@ -354,8 +488,29 @@ class Renderer { } } + // ⚠️ clearColor 必须是纯黑,不得改为读取 pattern?.preferredClearColor。 + // foveation 的 rasterizationRateMap 将渲染目标分成 tile,未被几何体覆盖的 + // tile 会被 clear 到此颜色。非黑色 clearColor 会造成可见的彩色瓦片伪影。 + // 见 notes/05-08-tile-artifacts-and-stereo-bugs.md let clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1) + // ── Compute pre-pass (before render encoder is created) ────────────────── + if let activePattern = pattern, let anchor = anchorToUse { + let viewData = makeViewRenderingData( + drawable: drawable, + deviceAnchor: anchor, + colorTexture: colorTexture, + viewCount: viewCount + ) + let prepassContext = PatternRenderContext( + viewData: viewData, + time: time, + renderTargetWidth: colorTexture.width, + renderTargetHeight: colorTexture.height + ) + activePattern.encodeComputePrepass(commandBuffer: commandBuffer, context: prepassContext) + } + guard let descriptor = makeRenderPassDescriptor( for: drawable, @@ -374,7 +529,9 @@ class Renderer { ) let sceneContext = PatternRenderContext( viewData: viewData, - time: time + time: time, + renderTargetWidth: colorTexture.width, + renderTargetHeight: colorTexture.height ) activePattern.encodeFrame(encoder: sceneEncoder, context: sceneContext) } @@ -404,9 +561,6 @@ class Renderer { } descriptor.rasterizationRateMap = drawable.rasterizationRateMaps.first - - // Always clear every slice in the drawable's texture, otherwise untouched - // foveation tiles stay black. descriptor.renderTargetArrayLength = colorTexture.arrayLength return descriptor @@ -447,6 +601,9 @@ class Renderer { matrices[index] = projection * viewMatrix let textureMap = view.textureMap viewports.append(textureMap.viewport) + // ⚠️ 必须用 sliceIndex,不是 textureIndex。 + // layered layout 下两眼在同一 texture 的不同 array slice(左眼 slice=0,右眼 slice=1)。 + // textureIndex 对两眼都是 0,会导致两眼画面叠到左眼,右眼黑屏。 renderTargetLayers.append(UInt32(textureMap.sliceIndex)) viewToWorldTransforms.append(adjustedWorldFromEye) } @@ -556,8 +713,12 @@ class Renderer { aizawaCount: Int, julia3DCount: Int, maxViewCount: Int - ) -> [VisualPatternKind: VisualPatternController] { + ) -> ( + controllers: [VisualPatternKind: VisualPatternController], + deferredBuilders: [VisualPatternKind: PatternControllerBuilder] + ) { var controllers: [VisualPatternKind: VisualPatternController] = [:] + var deferredBuilders: [VisualPatternKind: PatternControllerBuilder] = [:] do { controllers[.cubeField] = try CubeFieldRenderer( device: device, library: library, objectCount: cubeCount, maxViewCount: maxViewCount) @@ -575,78 +736,1049 @@ class Renderer { print("[Renderer] PongWar pattern unavailable.") } - if let lorenz = try? LorenzRenderer( + let polychoronConfigs: [(VisualPatternKind, RegularPolychoronKind, String)] = [ + (.fiveCellProjection, .fiveCell, "5-cell"), + (.eightCellProjection, .eightCell, "8-cell"), + (.sixteenCellProjection, .sixteenCell, "16-cell"), + (.twentyFourCellProjection, .twentyFourCell, "24-cell"), + (.oneHundredTwentyCellProjection, .oneHundredTwentyCell, "120-cell"), + (.sixHundredCellProjection, .sixHundredCell, "600-cell"), + ] + + for (patternKind, polychoronKind, label) in polychoronConfigs { + if let renderer = try? StereographicRenderer( + device: device, + library: library, + patternKind: patternKind, + polychoronKind: polychoronKind, + maxViewCount: maxViewCount + ) { + controllers[patternKind] = renderer + } else { + print("[Renderer] \(label) projection pattern unavailable.") + } + } + + deferredBuilders[.lorenzAttractor] = { + if let lorenz = try? LorenzRenderer( + device: device, + library: library, + particleCount: lorenzCount, + maxViewCount: maxViewCount + ) { + return lorenz + } + print("[Renderer] Lorenz attractor pattern unavailable; continuing with base pattern only.") + return nil + } + + deferredBuilders[.fourWingAttractor] = { + if let fourWing = try? FourWingRenderer( + device: device, + library: library, + particleCount: fourWingCount, + maxViewCount: maxViewCount + ) { + return fourWing + } + print("[Renderer] Four-Wing attractor pattern unavailable.") + return nil + } + + deferredBuilders[.aizawaAttractor] = { + if let aizawa = try? AizawaRenderer( + device: device, + library: library, + particleCount: aizawaCount, + maxViewCount: maxViewCount + ) { + return aizawa + } + print("[Renderer] Aizawa attractor pattern unavailable.") + return nil + } + + if let pagoda = try? PagodaSolidRenderer( device: device, library: library, - particleCount: lorenzCount, maxViewCount: maxViewCount ) { - controllers[.lorenzAttractor] = lorenz + controllers[.pagoda] = pagoda } else { - print("[Renderer] Lorenz attractor pattern unavailable; continuing with base pattern only.") + print("[Renderer] Pagoda pattern unavailable.") } - if let fourWing = try? FourWingRenderer( + if let julia3D = try? Julia3DRenderer( device: device, library: library, - particleCount: fourWingCount, + particleCount: julia3DCount, maxViewCount: maxViewCount ) { - controllers[.fourWingAttractor] = fourWing + controllers[.julia3D] = julia3D } else { - print("[Renderer] Four-Wing attractor pattern unavailable.") + print("[Renderer] Julia3D pattern unavailable.") } - if let aizawa = try? AizawaRenderer( + if let rhombic = try? RhombicDodecahedronRenderer( device: device, library: library, - particleCount: aizawaCount, maxViewCount: maxViewCount ) { - controllers[.aizawaAttractor] = aizawa + controllers[.rhombicDodecahedron] = rhombic } else { - print("[Renderer] Aizawa attractor pattern unavailable.") + print("[Renderer] RhombicDodecahedron pattern unavailable.") } - if let pagoda = try? PagodaSolidRenderer( + if let metaball = try? MetaballRenderer( device: device, library: library, maxViewCount: maxViewCount ) { - controllers[.pagoda] = pagoda + controllers[.metaball] = metaball } else { - print("[Renderer] Pagoda pattern unavailable.") + print("[Renderer] Metaball pattern unavailable.") } - if let julia3D = try? Julia3DRenderer( + if let quatPoly = try? QuatPolynomialRenderer( device: device, library: library, - particleCount: julia3DCount, maxViewCount: maxViewCount ) { - controllers[.julia3D] = julia3D + controllers[.quatPolynomial] = quatPoly } else { - print("[Renderer] Julia3D pattern unavailable.") + print("[Renderer] QuatPolynomial pattern unavailable.") } - return controllers - } + deferredBuilders[.huashan] = { + if let huashan = try? HuashanSplatRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + return huashan + } + print("[Renderer] Huashan 3DGS pattern unavailable (missing huashan.splat?).") + return nil + } - private static func addTetris3D( - to controllers: inout [VisualPatternKind: VisualPatternController], - device: MTLDevice, - library: MTLLibrary, - maxViewCount: Int, - gameManager: GameManager - ) { - let tetris = Tetris3DRenderer( + if let glassBox = try? GlassBoxRenderer( device: device, library: library, - maxViewCount: maxViewCount, - gameManager: gameManager - ) - controllers[.tetris3D] = tetris - print("[Renderer] Tetris3D pattern added.") + maxViewCount: maxViewCount + ) { + controllers[.glassBox] = glassBox + } else { + print("[Renderer] GlassBox pattern unavailable.") + } + + if let platonicMirror = try? PlatonicMirrorRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.platonicMirror] = platonicMirror + } else { + print("[Renderer] PlatonicMirror pattern unavailable.") + } + + if let synthwaveSunset = try? SynthwaveSunsetRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.synthwaveSunset] = synthwaveSunset + } else { + print("[Renderer] SynthwaveSunset pattern unavailable.") + } + + if let tunnel = try? TunnelRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.tunnel] = tunnel + } else { + print("[Renderer] Tunnel pattern unavailable.") + } + + if let cubicSpaceDivision = try? CubicSpaceDivisionRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.cubicSpaceDivision] = cubicSpaceDivision + } else { + print("[Renderer] CubicSpaceDivision pattern unavailable.") + } + + if let voxelEdges = try? VoxelEdgesRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.voxelEdges] = voxelEdges + } else { + print("[Renderer] VoxelEdges pattern unavailable.") + } + + if let pathTilesCube = try? PathTilesCubeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.pathTilesCube] = pathTilesCube + } else { + print("[Renderer] PathTilesCube pattern unavailable.") + } + + if let cartoonFractalCube = try? CartoonFractalCubeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.cartoonFractalCube] = cartoonFractalCube + } else { + print("[Renderer] CartoonFractalCube pattern unavailable.") + } + + if let gyroidEchoCube = try? GyroidEchoCubeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.gyroidEchoCube] = gyroidEchoCube + } else { + print("[Renderer] GyroidEchoCube pattern unavailable.") + } + + if let waveLatticeCube = try? WaveLatticeCubeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.waveLatticeCube] = waveLatticeCube + } else { + print("[Renderer] WaveLatticeCube pattern unavailable.") + } + + if let waveySpheres = try? WaveySpheresRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.waveySpheres] = waveySpheres + } else { + print("[Renderer] Wavey spheres pattern unavailable.") + } + + if let fractalFlythrough = try? FractalFlythroughRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.fractalFlythrough] = fractalFlythrough + } else { + print("[Renderer] Fractal Flythrough pattern unavailable.") + } + + if let apollonianIIv4 = try? ApollonianIIv4Renderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.apollonianIIv4] = apollonianIIv4 + } else { + print("[Renderer] Apollonian-II-v4 pattern unavailable.") + } + + if let magnetar = try? MagnetarRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.magnetar] = magnetar + } else { + print("[Renderer] Magnetar pattern unavailable.") + } + + if let spiraledLayers = try? SpiraledLayersRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.spiraledLayers] = spiraledLayers + } else { + print("[Renderer] Spiraled Layers pattern unavailable.") + } + + if let angleFire = try? AngleFireRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.angleFire] = angleFire + } else { + print("[Renderer] Angle Fire pattern unavailable.") + } + + if let glowingMountainLines = try? GlowingMountainLinesRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.glowingMountainLines] = glowingMountainLines + } else { + print("[Renderer] Glowing Mountain Lines pattern unavailable.") + } + + if let rayMarchingDemo = try? RayMarchingDemoRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.rayMarchingDemo] = rayMarchingDemo + print("[Renderer] RayMarchingDemo pattern added.") + } else { + print("[Renderer] Ray Marching Demo pattern unavailable.") + } + + if let cubeRayMarchDemo = try? CubeRayMarchDemoRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.cubeRayMarchDemo] = cubeRayMarchDemo + print("[Renderer] CubeRayMarchDemo pattern added.") + } else { + print("[Renderer] Cube Ray March Demo pattern unavailable.") + } + + if let hyperbolicGroupLimitSet = try? HyperbolicGroupLimitSetRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.hyperbolicGroupLimitSet] = hyperbolicGroupLimitSet + print("[Renderer] HyperbolicGroupLimitSet pattern added.") + } else { + print("[Renderer] Hyperbolic Group Limit Set pattern unavailable.") + } + + if let apolloSpiral = try? ApolloSpiralRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.apolloSpiral] = apolloSpiral + print("[Renderer] ApolloSpiral pattern added.") + } else { + print("[Renderer] Apollo Spiral pattern unavailable.") + } + + if let voxelTunnel = try? VoxelTunnelRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.voxelTunnel] = voxelTunnel + print("[Renderer] VoxelTunnel pattern added.") + } else { + print("[Renderer] Voxel tunnel pattern unavailable.") + } + + if let nearLoxodrome = try? NearLoxodromeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.nearLoxodrome] = nearLoxodrome + print("[Renderer] NearLoxodrome pattern added.") + } else { + print("[Renderer] Near Loxodrome pattern unavailable.") + } + + if let shield = try? ShieldRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.shield] = shield + print("[Renderer] Shield pattern added.") + } else { + print("[Renderer] Shield pattern unavailable.") + } + + if let digitalLines = try? DigitalLinesRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.digitalLines] = digitalLines + print("[Renderer] Digital Lines pattern added.") + } else { + print("[Renderer] Digital Lines pattern unavailable.") + } + + if let cloudyCrystal = try? CloudyCrystalRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.cloudyCrystal] = cloudyCrystal + print("[Renderer] Cloudy Crystal pattern added.") + } else { + print("[Renderer] Cloudy Crystal pattern unavailable.") + } + + if let shaderdoughFairy = try? ShaderdoughFairyRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.shaderdoughFairy] = shaderdoughFairy + print("[Renderer] Shaderdough Fairy pattern added.") + } else { + print("[Renderer] Shaderdough Fairy pattern unavailable.") + } + + if let crystalCubeLatticinioCore1 = try? CrystalCubeLatticinioCore1Renderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.crystalCubeLatticinioCore1] = crystalCubeLatticinioCore1 + print("[Renderer] Crystal Cube Latticinio core 1 pattern added.") + } else { + print("[Renderer] Crystal Cube Latticinio core 1 pattern unavailable.") + } + + if let fireTornado = try? FireTornadoRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.fireTornado] = fireTornado + print("[Renderer] Fire Tornado pattern added.") + } else { + print("[Renderer] Fire Tornado pattern unavailable.") + } + + if let reflectiveWythoffPolyhedra = try? ReflectiveWythoffPolyhedraRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.reflectiveWythoffPolyhedra] = reflectiveWythoffPolyhedra + print("[Renderer] Reflective Wythoff polyhedra pattern added.") + } else { + print("[Renderer] Reflective Wythoff polyhedra pattern unavailable.") + } + + if let apollonian = try? ApollonianRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.apollonian] = apollonian + print("[Renderer] apollonian pattern added.") + } else { + print("[Renderer] apollonian pattern unavailable.") + } + + if let apollonianTwist = try? ApollonianTwistRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.apollonianTwist] = apollonianTwist + print("[Renderer] Apollonian Twist pattern added.") + } else { + print("[Renderer] Apollonian Twist pattern unavailable.") + } + + if let steampunkOrb = try? SteampunkOrbRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.steampunkOrb] = steampunkOrb + print("[Renderer] Steampunk Orb pattern added.") + } else { + print("[Renderer] Steampunk Orb pattern unavailable.") + } + + if let apollonianWires = try? ApollonianWiresRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.apollonianWires] = apollonianWires + print("[Renderer] Apollonian Wires pattern added.") + } else { + print("[Renderer] Apollonian Wires pattern unavailable.") + } + + if let kuKo = try? KuKoRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.kuKo] = kuKo + print("[Renderer] KuKo pattern added.") + } else { + print("[Renderer] KuKo pattern unavailable.") + } + + if let sonicAndTails = try? SonicAndTailsRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.sonicAndTails] = sonicAndTails + print("[Renderer] Sonic & Tails pattern added.") + } else { + print("[Renderer] Sonic & Tails pattern unavailable.") + } + + if let followYourLight = try? FollowYourLightRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.followYourLight] = followYourLight + print("[Renderer] Follow Your Light pattern added.") + } else { + print("[Renderer] Follow Your Light pattern unavailable.") + } + + if let weirdSurface = try? WeirdSurfaceRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.weirdSurface] = weirdSurface + print("[Renderer] Weird Surface pattern added.") + } else { + print("[Renderer] Weird Surface pattern unavailable.") + } + + if let neonShells = try? NeonShellsRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.neonShells] = neonShells + print("[Renderer] Neon Shells pattern added.") + } else { + print("[Renderer] Neon Shells pattern unavailable.") + } + + if let magneticLinesThatDrawInGold = try? MagneticLinesThatDrawInGoldRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.magneticLinesThatDrawInGold] = magneticLinesThatDrawInGold + print("[Renderer] Magnetic lines that draw in gold pattern added.") + } else { + print("[Renderer] Magnetic lines that draw in gold pattern unavailable.") + } + + if let lanterns = try? LanternsRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.lanterns] = lanterns + print("[Renderer] Lanterns pattern added.") + } else { + print("[Renderer] Lanterns pattern unavailable.") + } + + if let laceTunnel = try? LaceTunnelRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.laceTunnel] = laceTunnel + print("[Renderer] Lace Tunnel pattern added.") + } else { + print("[Renderer] Lace Tunnel pattern unavailable.") + } + + if let torusFan = try? TorusFanRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.torusFan] = torusFan + print("[Renderer] Torus fan pattern added.") + } else { + print("[Renderer] Torus fan pattern unavailable.") + } + + if let apollonianElevator = try? ApollonianElevatorRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.apollonianElevator] = apollonianElevator + print("[Renderer] Apollonian Elevator pattern added.") + } else { + print("[Renderer] Apollonian Elevator pattern unavailable.") + } + + if let torusKnotInR4 = try? TorusKnotInR4Renderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.torusKnotInR4] = torusKnotInR4 + print("[Renderer] Torus Knot in R4 pattern added.") + } else { + print("[Renderer] Torus Knot in R4 pattern unavailable.") + } + + if let threeDFire = try? ThreeDFireRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.threeDFire] = threeDFire + print("[Renderer] 3D Fire pattern added.") + } else { + print("[Renderer] 3D Fire pattern unavailable.") + } + + if let bubbleRings = try? BubbleRingsRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.bubbleRings] = bubbleRings + print("[Renderer] Bubble rings pattern added.") + } else { + print("[Renderer] Bubble rings pattern unavailable.") + } + + if let ether = try? EtherRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.ether] = ether + print("[Renderer] Ether pattern added.") + } else { + print("[Renderer] Ether pattern unavailable.") + } + + if let fiberSpiral = try? FiberSpiralRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.fiberSpiral] = fiberSpiral + print("[Renderer] Fiber Spiral pattern added.") + } else { + print("[Renderer] Fiber Spiral pattern unavailable.") + } + + if let saturdayTorus = try? SaturdayTorusRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.saturdayTorus] = saturdayTorus + print("[Renderer] Saturday Torus pattern added.") + } else { + print("[Renderer] Saturday Torus pattern unavailable.") + } + + if let tesseractCornerFractal = try? TesseractCornerFractalRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.tesseractCornerFractal] = tesseractCornerFractal + print("[Renderer] Tesseract Corner Fractal pattern added.") + } else { + print("[Renderer] Tesseract Corner Fractal pattern unavailable.") + } + + if let hexwaves = try? HexwavesRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.hexwaves] = hexwaves + print("[Renderer] hexwaves pattern added.") + } else { + print("[Renderer] hexwaves pattern unavailable.") + } + + if let milosRose = try? MilosRoseRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.milosRose] = milosRose + print("[Renderer] Milo's Rose pattern added.") + } else { + print("[Renderer] Milo's Rose pattern unavailable.") + } + + if let recursiveLotus = try? RecursiveLotusRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.recursiveLotus] = recursiveLotus + print("[Renderer] Recursive Lotus pattern added.") + } else { + print("[Renderer] Recursive Lotus pattern unavailable.") + } + + if let blueFlower = try? BlueFlowerRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.blueFlower] = blueFlower + print("[Renderer] Blue Flower pattern added.") + } else { + print("[Renderer] Blue Flower pattern unavailable.") + } + + if let flowerTest = try? FlowerTestRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.flowerTest] = flowerTest + print("[Renderer] Flower Test pattern added.") + } else { + print("[Renderer] Flower Test pattern unavailable.") + } + + if let floreus = try? FloreusRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.floreus] = floreus + print("[Renderer] Floreus pattern added.") + } else { + print("[Renderer] Floreus pattern unavailable.") + } + + if let sineBud = try? SineBudRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.sineBud] = sineBud + print("[Renderer] Sine bud pattern added.") + } else { + print("[Renderer] Sine bud pattern unavailable.") + } + + if let saturdayWeirdness = try? SaturdayWeirdnessRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.saturdayWeirdness] = saturdayWeirdness + print("[Renderer] Saturday weirdness pattern added.") + } else { + print("[Renderer] Saturday weirdness pattern unavailable.") + } + + if let soulstone = try? SoulstoneRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.soulstone] = soulstone + print("[Renderer] Soulstone pattern added.") + } else { + print("[Renderer] Soulstone pattern unavailable.") + } + + if let boxOfStars = try? BoxOfStarsRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.boxOfStars] = boxOfStars + print("[Renderer] Box of Stars pattern added.") + } else { + print("[Renderer] Box of Stars pattern unavailable.") + } + + if let mirrorLooping = try? MirrorLoopingRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.mirrorLooping] = mirrorLooping + print("[Renderer] Mirror Looping pattern added.") + } else { + print("[Renderer] Mirror Looping pattern unavailable.") + } + + if let greatDodecaheadroll = try? GreatDodecaheadrollRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.greatDodecaheadroll] = greatDodecaheadroll + print("[Renderer] Great Dodecaheadroll pattern added.") + } else { + print("[Renderer] Great Dodecaheadroll pattern unavailable.") + } + + if let playingMarble = try? PlayingMarbleRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.playingMarble] = playingMarble + print("[Renderer] Playing marble pattern added.") + } else { + print("[Renderer] Playing marble pattern unavailable.") + } + + if let novaMarble = try? NovaMarbleRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.novaMarble] = novaMarble + print("[Renderer] Nova Marble pattern added.") + } else { + print("[Renderer] Nova Marble pattern unavailable.") + } + + if let dirtBall = try? DirtBallRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.dirtBall] = dirtBall + print("[Renderer] Dirt Ball pattern added.") + } else { + print("[Renderer] Dirt Ball pattern unavailable.") + } + + if let fractal49Gaz = try? Fractal49GazRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.fractal49Gaz] = fractal49Gaz + print("[Renderer] Fractal 49_gaz pattern added.") + } else { + print("[Renderer] Fractal 49_gaz pattern unavailable.") + } + + if let starryPlanes = try? StarryPlanesRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.starryPlanes] = starryPlanes + print("[Renderer] Starry planes pattern added.") + } else { + print("[Renderer] Starry planes pattern unavailable.") + } + + if let fractal77Gaz = try? Fractal77GazRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.fractal77Gaz] = fractal77Gaz + print("[Renderer] Fractal 77_gaz pattern added.") + } else { + print("[Renderer] Fractal 77_gaz pattern unavailable.") + } + + if let poincareBallHoneycomb = try? PoincareBallHoneycombRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.poincareBallHoneycomb] = poincareBallHoneycomb + print("[Renderer] Poincare Ball Honeycomb pattern added.") + } else { + print("[Renderer] Poincare Ball Honeycomb pattern unavailable.") + } + + if let goldenApollian = try? GoldenApollianRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.goldenApollian] = goldenApollian + print("[Renderer] Golden apollian pattern added.") + } else { + print("[Renderer] Golden apollian pattern unavailable.") + } + + if let anotherMarble = try? AnotherMarbleRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.anotherMarble] = anotherMarble + print("[Renderer] Another Marble pattern added.") + } else { + print("[Renderer] Another Marble pattern unavailable.") + } + + if let petalsFractal = try? PetalsFractalRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.petalsFractal] = petalsFractal + print("[Renderer] Petals Fractal pattern added.") + } else { + print("[Renderer] Petals Fractal pattern unavailable.") + } + + if let marbleMovingRemix = try? MarbleMovingRemixRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.marbleMovingRemix] = marbleMovingRemix + print("[Renderer] marble moving remix pattern added.") + } else { + print("[Renderer] marble moving remix pattern unavailable.") + } + + if let tunnelingThroughApollianFrac = try? TunnelingThroughApollianFracRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.tunnelingThroughApollianFrac] = tunnelingThroughApollianFrac + print("[Renderer] Tunneling through apollian frac pattern added.") + } else { + print("[Renderer] Tunneling through apollian frac pattern unavailable.") + } + + if let slicesInMarbles = try? SlicesInMarblesRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.slicesInMarbles] = slicesInMarbles + print("[Renderer] slices in marbles pattern added.") + } else { + print("[Renderer] slices in marbles pattern unavailable.") + } + + if let logSphericalKIFSZoomer = try? LogSphericalKIFSZoomerRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.logSphericalKIFSZoomer] = logSphericalKIFSZoomer + print("[Renderer] Log Spherical KIFS Zoomer pattern added.") + } else { + print("[Renderer] Log Spherical KIFS Zoomer pattern unavailable.") + } + + if let fractalCity = try? FractalCityRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.fractalCity] = fractalCity + print("[Renderer] Fractal city pattern added.") + } else { + print("[Renderer] Fractal city pattern unavailable.") + } + + if let interferenceCascadeCube = try? InterferenceCascadeCubeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.interferenceCascadeCube] = interferenceCascadeCube + } else { + print("[Renderer] InterferenceCascadeCube pattern unavailable.") + } + + if let orbitalSphereCube = try? OrbitalSphereCubeRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.orbitalSphereCube] = orbitalSphereCube + } else { + print("[Renderer] OrbitalSphereCube pattern unavailable.") + } + + if let starTrails = try? StarTrailsRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.starTrails] = starTrails + print("[Renderer] StarTrails pattern added.") + } else { + print("[Renderer] StarTrails pattern unavailable.") + } + + if let particleRain = try? ParticleRainRenderer( + device: device, + library: library, + maxViewCount: maxViewCount + ) { + controllers[.particleRain] = particleRain + print("[Renderer] ParticleRain pattern added.") + } else { + print("[Renderer] ParticleRain pattern unavailable.") + } + + return (controllers: controllers, deferredBuilders: deferredBuilders) + } + + private static func addTetris3D( + to controllers: inout [VisualPatternKind: VisualPatternController], + device: MTLDevice, + library: MTLLibrary, + maxViewCount: Int, + gameManager: GameManager + ) { + let tetris = Tetris3DRenderer( + device: device, + library: library, + maxViewCount: maxViewCount, + gameManager: gameManager + ) + controllers[.tetris3D] = tetris + print("[Renderer] Tetris3D pattern added.") + } + + private static func addSnake3D( + to controllers: inout [VisualPatternKind: VisualPatternController], + device: MTLDevice, + library: MTLLibrary, + maxViewCount: Int, + gameManager: GameManager + ) { + let snake = Snake3DRenderer( + device: device, + library: library, + maxViewCount: maxViewCount, + gameManager: gameManager + ) + controllers[.snake3D] = snake + print("[Renderer] Snake3D pattern added.") } } diff --git a/vr-dive/Renderer/RendererTypes.swift b/vr-dive/Renderer/RendererTypes.swift index 5d65dfe..fd43cb2 100644 --- a/vr-dive/Renderer/RendererTypes.swift +++ b/vr-dive/Renderer/RendererTypes.swift @@ -25,11 +25,17 @@ struct PatternSimulationContext { let commandQueue: MTLCommandQueue let time: Float let speedMultiplier: Float + let isPaused: Bool + let originCellInspectionEnabled: Bool + let rayMarchingProbeDimTarget: RayMarchingProbeDimTarget + let huashanSampleRatio: Float } struct PatternRenderContext { let viewData: ViewRenderingData let time: Float + let renderTargetWidth: Int // actual color texture width (not display viewport) + let renderTargetHeight: Int // actual color texture height func applyViewConfiguration(on encoder: MTLRenderCommandEncoder) { if !viewData.viewports.isEmpty { @@ -45,6 +51,11 @@ struct PatternRenderContext { } encoder.setVertexAmplificationCount(viewData.viewCount, viewMappings: &viewMappings) } else { + // ⚠️ 必须保留此 else 分支,即使 viewCount == 1。 + // foveation 开启时,Metal 需要显式调用 setVertexAmplificationCount 才能 + // 正确将虚拟 viewport 坐标(如 4338×3478)映射到物理 texture(如 1888×1792)。 + // 缺少此调用会导致未被几何体覆盖的 foveation tile 以 clear color 颜色暴露, + // 形成可见的彩色瓦片伪影(tile artifacts)。见 notes/05-08-tile-artifacts-and-stereo-bugs.md encoder.setVertexAmplificationCount(1, viewMappings: nil) } } @@ -54,7 +65,22 @@ protocol VisualPatternController: AnyObject { var identifier: VisualPatternKind { get } var preferredClearColor: MTLClearColor { get } + func synchronizeState(_ context: PatternSimulationContext) func updateSimulation(_ context: PatternSimulationContext) + /// Optional compute pre-pass (runs before the render encoder is created). + /// Use this to dispatch compute kernels that produce data consumed by encodeFrame. + func encodeComputePrepass(commandBuffer: MTLCommandBuffer, context: PatternRenderContext) func encodeFrame(encoder: MTLRenderCommandEncoder, context: PatternRenderContext) func resetToInitialState() + + /// Optional pre-warm hook. Called once at startup on the CPU thread to force + /// Metal's JIT pipeline compilation before the first real frame is submitted. + /// Implement this for any pattern whose shader compilation takes >100 ms. + func warmupPipeline(device: MTLDevice, commandQueue: MTLCommandQueue) +} + +extension VisualPatternController { + func synchronizeState(_ context: PatternSimulationContext) {} + func encodeComputePrepass(commandBuffer: MTLCommandBuffer, context: PatternRenderContext) {} + func warmupPipeline(device: MTLDevice, commandQueue: MTLCommandQueue) {} } diff --git a/vr-dive/ToggleImmersiveSpaceButton.swift b/vr-dive/ToggleImmersiveSpaceButton.swift index dd8a738..d594e3c 100644 --- a/vr-dive/ToggleImmersiveSpaceButton.swift +++ b/vr-dive/ToggleImmersiveSpaceButton.swift @@ -56,9 +56,10 @@ struct ToggleImmersiveSpaceButton: View { Text(appModel.immersiveSpaceState == .open ? "退出沉浸模式" : "进入沉浸模式") } .disabled(appModel.immersiveSpaceState == .inTransition) + .buttonStyle(.bordered) .animation(.none, value: 0) - .font(.title2) + .font(.headline) .fontWeight(.semibold) - .padding() + .padding(.vertical, 4) } } diff --git a/vr-dive/vr_diveApp.swift b/vr-dive/vr_diveApp.swift index 0799e67..b0c8747 100644 --- a/vr-dive/vr_diveApp.swift +++ b/vr-dive/vr_diveApp.swift @@ -23,7 +23,7 @@ struct vr_diveApp: App { .environment(appModel) } } - .defaultSize(width: 480, height: 260) + .defaultSize(width: 700, height: 300) ImmersiveSpace(id: appModel.immersiveSpaceID) { CompositorLayer(configuration: VRConfiguration()) { layerRenderer in