Last active
August 8, 2024 17:52
-
-
Save Matt54/921cc3fb9ea2e8fae3681f9ff05e267a to your computer and use it in GitHub Desktop.
RealityView where a cylinder's detected SpatialEventGesture modulates a signal generator's output
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import AVFoundation | |
import SwiftUI | |
import RealityKit | |
struct LaserSynthView: View { | |
@Environment(\.physicalMetrics) var physicalMetrics | |
let signalGenerator = SignalGenerator() | |
@State var outerCylinderEntity: Entity? | |
@State var innerCylinderEntity: Entity? | |
@State var touchEntity: Entity? | |
@State private var timer: Timer? | |
@State private var time: Double = 0.0 | |
@State private var rotationAngles: SIMD3<Float> = [0, 0, 0] | |
@State private var lastRotationUpdateTime = CACurrentMediaTime() | |
var body: some View { | |
GeometryReader3D { proxy in | |
RealityView { content in | |
let size = content.convert(proxy.frame(in: .local), from: .local, to: .scene).extents | |
let boxSize = size.y * 0.1 | |
let cylinderSize = size.y-boxSize*2 | |
let capRadius = cylinderSize * 0.05 | |
let outerCylinderRadius = capRadius * 0.75 | |
let innerCylinderRadius = outerCylinderRadius * 0.25 | |
let touchEntityRadius = outerCylinderRadius * 3.0 | |
let outerCylinderEntity = await getCylinderEntity(height: cylinderSize, radius: outerCylinderRadius) | |
let innerCylinderEntity = await getCylinderEntity(includeCollision: false, height: cylinderSize, radius: innerCylinderRadius) | |
let touchEntity = await getTouchEntity(radius: touchEntityRadius) | |
content.add(innerCylinderEntity) | |
content.add(outerCylinderEntity) | |
// We wrap touchEntity with a parent that has the constant offset to align the gesture location with touchEntity's transform | |
let touchEntityWrapperForConstantOffset = Entity() | |
touchEntityWrapperForConstantOffset.addChild(touchEntity) | |
touchEntityWrapperForConstantOffset.transform.translation.y = size.y*0.5// + sphereRadius*0.5 | |
content.add(touchEntityWrapperForConstantOffset) | |
touchEntity.components.set(OpacityComponent.init(opacity: 0.0)) | |
innerCylinderEntity.components.set(OpacityComponent.init(opacity: 0.0)) | |
let sortGroup = ModelSortGroup(depthPass: .postPass) | |
touchEntityWrapperForConstantOffset.applyRecursively { entity in | |
entity.components.set(ModelSortGroupComponent(group: sortGroup, order: 1)) | |
} | |
outerCylinderEntity.applyRecursively { entity in | |
entity.components.set(ModelSortGroupComponent(group: sortGroup, order: 0)) | |
} | |
innerCylinderEntity.applyRecursively { entity in | |
entity.components.set(ModelSortGroupComponent(group: sortGroup, order: 0)) | |
} | |
let boxTranslation = (size.y - boxSize) * 0.5 | |
let topCapEntity = getCapEntity(size: boxSize) | |
topCapEntity.transform.translation.y = boxTranslation | |
content.add(topCapEntity) | |
let bottomCapEntity = getCapEntity(size: boxSize) | |
bottomCapEntity.transform.translation.y = -boxTranslation | |
content.add(bottomCapEntity) | |
self.touchEntity = touchEntity | |
self.outerCylinderEntity = outerCylinderEntity | |
self.innerCylinderEntity = innerCylinderEntity | |
} | |
.upperLimbVisibility(.hidden) | |
.gesture( | |
SpatialEventGesture(coordinateSpace: .local) | |
.onChanged { events in | |
guard let outerCylinderEntity, let innerCylinderEntity, let touchEntity else { return } | |
outerCylinderEntity.components.set(OpacityComponent.init(opacity: 0.25)) | |
innerCylinderEntity.components.set(OpacityComponent.init(opacity: 0.5)) | |
touchEntity.components.set(OpacityComponent.init(opacity: 1.0)) | |
print("onChanged") | |
for event in events { | |
var y: Float = 0 | |
switch event.kind { | |
case .touch, .indirectPinch: | |
y = Float(physicalMetrics.convert(event.location3D.y, to: .meters)) | |
touchEntity.transform.translation.y = y * -1 | |
case .directPinch: | |
print("directPinch") | |
case .pointer: | |
print("pointer") | |
@unknown default: | |
print("unknown default") | |
} | |
let minFreq: Float = 10 | |
let maxFreq: Float = 1000 | |
let cylinderHeight: Float = 0.4 | |
let normalizedY = (y + cylinderHeight / 2) / cylinderHeight | |
let frequency = minFreq + (maxFreq - minFreq) * normalizedY | |
signalGenerator.signalFrequency = Double(frequency) | |
signalGenerator.setFilterFrequency(Float(frequency)) | |
signalGenerator.play() | |
} | |
} | |
.onEnded { events in | |
guard let outerCylinderEntity, let innerCylinderEntity, let touchEntity else { return } | |
outerCylinderEntity.components.set(OpacityComponent.init(opacity: 1.0)) | |
innerCylinderEntity.components.set(OpacityComponent.init(opacity: 0.0)) | |
touchEntity.components.set(OpacityComponent.init(opacity: 0.0)) | |
print("onEnded") | |
signalGenerator.stop() | |
} | |
) | |
.onAppear { startTimer() } | |
.onDisappear { stopTimer() } | |
} | |
} | |
private func startTimer() { | |
timer = Timer.scheduledTimer(withTimeInterval: 1/120.0, repeats: true) { _ in | |
let currentTime = CACurrentMediaTime() | |
let frameDuration = currentTime - lastRotationUpdateTime | |
self.time += frameDuration | |
// Rotate along all axis at different rates for a wonky rotation effect | |
rotationAngles.x += Float(frameDuration * 3.0) | |
rotationAngles.y += Float(frameDuration * 1.4) | |
rotationAngles.z += Float(frameDuration * 0.9) | |
let rotationX = simd_quatf(angle: rotationAngles.x, axis: [1, 0, 0]) | |
let rotationY = simd_quatf(angle: rotationAngles.y, axis: [0, 1, 0]) | |
let rotationZ = simd_quatf(angle: rotationAngles.z, axis: [0, 0, 1]) | |
touchEntity?.transform.rotation = rotationX * rotationY * rotationZ | |
lastRotationUpdateTime = currentTime | |
} | |
} | |
func stopTimer() { | |
timer?.invalidate() | |
timer = nil | |
} | |
func getCylinderEntity(includeCollision: Bool = true, height: Float, radius: Float = 0.01) async -> Entity { | |
let entity = Entity() | |
if includeCollision { | |
let collisionRadius = radius*1.5 | |
let collisionComponent = CollisionComponent(shapes: [.generateBox(width: collisionRadius, height: height, depth: collisionRadius)]) | |
entity.components.set(collisionComponent) | |
entity.components.set(InputTargetComponent()) | |
} | |
// loop to create glow effect | |
let count = 50 | |
for i in 0..<count { | |
let fraction = Float(i) / Float(count) | |
let newRadius = radius * (1.0 - fraction * 1.0) | |
let opacity = pow(fraction, 2) // Quadratic exaggerates effect | |
let childEntity = Entity() | |
let modelComponent = await getCylinderModelComponent(height: height,radius: newRadius, opacity: opacity) | |
childEntity.components.set(modelComponent) | |
entity.addChild(childEntity) | |
} | |
return entity | |
} | |
func getTouchEntity(radius: Float) async -> Entity { | |
let sphereEntity = Entity() | |
// loop to create glow effect | |
let numSpheres = 50 | |
for i in 0..<numSpheres { | |
let fraction = Float(i) / Float(numSpheres) | |
let sphereRadius = radius * (1.0 - fraction * 1.0) | |
let opacity = pow(fraction, 4) // Quadratic exaggerates effect | |
let sphere = Entity() | |
let modelComponent = await getTouchModelComponent(radius: sphereRadius, opacity: opacity) | |
sphere.components.set(modelComponent) | |
sphereEntity.addChild(sphere) | |
} | |
return sphereEntity | |
} | |
func getCapEntity(size: Float) -> Entity { | |
let entity = Entity() | |
let mesh = MeshResource.generateCylinder(height: size, radius: size*0.5) | |
var material = PhysicallyBasedMaterial() | |
material.baseColor.tint = .gray | |
material.metallic = 1.0 | |
material.roughness = 0.25 | |
let modelComponent = ModelComponent(mesh: mesh, materials: [material]) | |
entity.components.set(modelComponent) | |
return entity | |
} | |
func getCylinderModelComponent(height: Float, radius: Float, opacity: Float = 1.0) async -> ModelComponent { | |
let resource = MeshResource.generateCylinder(height: height, radius: radius) | |
let material = await generateAddMaterial(color: .init(red: 0.5, green: 0.5, blue: 1.0, alpha: 1.0), opacity: opacity) // | |
let modelComponent = ModelComponent(mesh: resource, materials: [material]) | |
return modelComponent | |
} | |
func getTouchModelComponent(radius: Float, opacity: Float) async -> ModelComponent { | |
var material = await generateAddMaterial(color: .init(red: 0.5, green: 0.5, blue: 1.0, alpha: 1.0), opacity: opacity) | |
material.faceCulling = .back | |
let sphereMesh = try! MeshResource.generateSpecificSphere(radius: radius, latitudeBands: 8, longitudeBands: 8) | |
return ModelComponent(mesh: sphereMesh, materials: [material]) | |
} | |
func generateAddMaterial(color: UIColor, opacity: Float = 1.0) async -> UnlitMaterial { | |
var descriptor = UnlitMaterial.Program.Descriptor() | |
descriptor.blendMode = .add | |
let prog = await UnlitMaterial.Program(descriptor: descriptor) | |
var material = UnlitMaterial(program: prog) | |
material.color = UnlitMaterial.BaseColor(tint: color) | |
material.blending = .transparent(opacity: .init(floatLiteral: opacity)) | |
return material | |
} | |
} | |
#Preview { | |
LaserSynthView() | |
} | |
extension Entity { | |
func applyRecursively(_ block: (Entity) -> Void) { | |
block(self) | |
for child in children { | |
child.applyRecursively(block) | |
} | |
} | |
} | |
// MARK: Audio Stuff | |
class SignalGenerator { | |
var signalFrequency: Double = 250.0 | |
private var isPlaying = false | |
private var noiseVolume: Float = 0.5 | |
private var filterFrequency: Float = 1000.0 | |
private var engine = AVAudioEngine() | |
private var signalNode: AVAudioSourceNode? | |
private var noiseNode: AVAudioSourceNode? | |
private var lowPassFilter: AVAudioUnitEQ? | |
init() { | |
configureAudioSession() | |
setupAudio() | |
} | |
private func setupAudio() { | |
let mainMixer = engine.mainMixerNode | |
let output = engine.outputNode | |
let format = output.inputFormat(forBus: 0) | |
let signalNode = AVAudioSourceNode { _, _, frameCount, audioBufferList -> OSStatus in | |
let ablPointer = UnsafeMutableAudioBufferListPointer(audioBufferList) | |
var phase: Double = 0 | |
let phaseIncrement = self.signalFrequency / format.sampleRate | |
for frame in 0..<Int(frameCount) { | |
// Saw wave generation | |
let value = 2 * (phase - floor(0.5 + phase)) | |
for buffer in ablPointer { | |
let buf: UnsafeMutableBufferPointer<Float> = UnsafeMutableBufferPointer(buffer) | |
buf[frame] = Float(value) * 0.1 // Reduced volume | |
} | |
phase += phaseIncrement | |
if phase >= 1.0 { | |
phase -= 1.0 | |
} | |
} | |
return noErr | |
} | |
signalNode.volume = 0.0 | |
// Create a noise generator node | |
let noiseNode = AVAudioSourceNode { _, _, frameCount, audioBufferList -> OSStatus in | |
let ablPointer = UnsafeMutableAudioBufferListPointer(audioBufferList) | |
for frame in 0..<Int(frameCount) { | |
let whiteNoise = Float.random(in: -1...1) * self.noiseVolume | |
for buffer in ablPointer { | |
let buf: UnsafeMutableBufferPointer<Float> = UnsafeMutableBufferPointer(buffer) | |
buf[frame] = whiteNoise | |
} | |
} | |
return noErr | |
} | |
noiseNode.volume = 0.0 | |
let lowPassFilter = AVAudioUnitEQ(numberOfBands: 1) | |
lowPassFilter.bands[0].filterType = .lowPass | |
lowPassFilter.bands[0].frequency = 1000 // Cutoff frequency in Hz | |
lowPassFilter.bands[0].bandwidth = 1.0 | |
lowPassFilter.bands[0].bypass = false | |
// Create a mixer node to combine the saw wave and noise | |
let mixerNode = AVAudioMixerNode() | |
let reverbNode = AVAudioUnitReverb() | |
reverbNode.loadFactoryPreset(.largeChamber) | |
reverbNode.wetDryMix = 50 | |
engine.attach(signalNode) | |
engine.attach(noiseNode) | |
engine.attach(lowPassFilter) | |
engine.attach(mixerNode) | |
engine.attach(reverbNode) | |
engine.connect(signalNode, to: mixerNode, format: format) | |
engine.connect(noiseNode, to: mixerNode, format: format) | |
engine.connect(mixerNode, to: lowPassFilter, format: format) | |
engine.connect(lowPassFilter, to: reverbNode, format: format) | |
engine.connect(reverbNode, to: mainMixer, format: format) | |
engine.connect(mainMixer, to: output, format: format) | |
do { | |
try engine.start() | |
} catch { | |
print("Could not start engine: \(error.localizedDescription)") | |
} | |
self.signalNode = signalNode | |
self.noiseNode = noiseNode | |
self.lowPassFilter = lowPassFilter | |
} | |
func configureAudioSession() { | |
do { | |
let audioSession = AVAudioSession.sharedInstance() | |
try audioSession.setIntendedSpatialExperience(.bypassed) | |
try audioSession.setActive(true) | |
} catch { | |
print(error) | |
} | |
} | |
func play() { | |
guard !isPlaying else { return } | |
signalNode?.volume = 1.0 | |
noiseNode?.volume = 0.125 | |
isPlaying = true | |
} | |
func setFilterFrequency(_ newFrequency: Float) { | |
filterFrequency = newFrequency | |
lowPassFilter?.bands[0].frequency = filterFrequency | |
} | |
func stop() { | |
signalNode?.volume = 0.0 | |
noiseNode?.volume = 0.0 | |
isPlaying = false | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment