Skip to content

Instantly share code, notes, and snippets.

@Matt54
Last active August 8, 2024 17:52
Show Gist options
  • Save Matt54/921cc3fb9ea2e8fae3681f9ff05e267a to your computer and use it in GitHub Desktop.
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
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