Highlight
Apple 用 HapticRicochet 示例展示了如何把 Core Haptics、音频和动画绑定到同一个交互瞬间,让 iPhone 应用的碰撞、护盾和滚动纹理反馈来源清楚、节奏一致、只在关键动作出现。
核心内容
做一个小游戏时,画面反馈最容易实现。球撞到边界,可以改变速度;用户点一下球,可以播放动画;背景纹理可以换一张图。
问题出在触感和声音。
如果碰撞有声音,却没有触感,球会像贴在屏幕上的图层。如果触感出现得太频繁,用户会觉得吵。如果声音和触感节奏不同,交互会变得松散。
这场 session 用 HapticRicochet 讲这个问题。它来自 HapticBounce 示例。球会根据 iPhone 的方向滚动,撞墙时产生音频和触感。用户点球后,球变大;再点一次,球获得护盾。护盾会在碰撞中损耗,最后球会内爆。
Apple 把重点放在两个动作上。
第一个动作是添加护盾。这个瞬间有 500 毫秒视觉动画,也需要声音和触感一起表达“获得保护”的状态变化。
第二个动作是开启滚动纹理。用户点背景后,背景出现圆点纹理。球滚过纹理时,触感要持续出现,并随球速变化。
Core Haptics(核心触感框架)提供了底层能力。它把反馈拆成四个对象:engine(引擎)、player(播放器)、pattern(模式)、event(事件)。开发者先从 AHAP(Apple Haptic Audio Pattern,苹果触感音频模式)文件创建模式,再创建播放器,最后在交互发生时播放。
这套流程解决的是协作问题。设计师可以在 AHAP 里调整触感和音频。工程师把模式接到真实交互上。两边用同一个文件讨论反馈,减少“更硬一点”或“更厚一点”这类抽象形容词。
详细内容
Core Haptics 的四个对象
(04:41)
Core Haptics 的 API 围绕四个对象工作:engine、player、pattern、event。
engine 连接 iPhone 的物理执行器。player 控制播放、停止和暂停。pattern 是按时间排列的事件集合。event 是实际的反馈单元,常见类型包括 transient(瞬态)和 continuous(连续)。
session 提到,实践部分会聚焦三件事:加载模式、修改模式、播放模式。
import CoreHaptics
final class HapticController {
private let engine: CHHapticEngine
private var player: CHHapticPatternPlayer?
init() throws {
engine = try CHHapticEngine()
try engine.start()
}
func loadPattern(named name: String) throws {
guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
return
}
let pattern = try CHHapticPattern(contentsOf: url)
player = try engine.makePlayer(with: pattern)
}
func play() throws {
try player?.start(atTime: CHHapticTimeImmediate)
}
}
关键点:
import CoreHaptics引入触感框架。CHHapticEngine是播放触感的入口,对应 session 里的 engine。try engine.start()启动引擎,之后才能播放模式。Bundle.main.url(forResource:withExtension:)从应用包读取.ahap文件。CHHapticPattern(contentsOf:)从 AHAP 文件创建 pattern。engine.makePlayer(with:)从 pattern 创建 player。start(atTime: CHHapticTimeImmediate)立即播放当前模式。
护盾反馈:先加载模式,再在动画时播放
(08:05)
护盾动作分成两步。
第一步在初始化阶段创建模式。session 的代码从 ShieldTransient 这个 AHAP 文件创建 pattern,再用 engine.makePlayer(with:) 创建 shieldPlayer。
第二步在护盾动画开始时播放反馈。shield() 函数调用 startPlayer(shieldPlayer),随后把 shieldAnimation 加到 sphereView.layer 上。
import UIKit
import CoreHaptics
final class ShieldViewController: UIViewController {
private var engine: CHHapticEngine!
private var shieldPlayer: CHHapticPatternPlayer?
private let sphereView = UIView()
private let shieldAnimation = CABasicAnimation(keyPath: "borderWidth")
private var isAnimating = false
override func viewDidLoad() {
super.viewDidLoad()
engine = try? CHHapticEngine()
try? engine.start()
initializeShieldHaptics()
}
func initializeShieldHaptics() {
guard let pattern = createPatternFromAHAP("ShieldTransient") else {
return
}
shieldPlayer = try? engine.makePlayer(with: pattern)
}
func shield() {
startPlayer(shieldPlayer)
isAnimating = true
sphereView.layer.add(shieldAnimation, forKey: "Width")
}
private func createPatternFromAHAP(_ name: String) -> CHHapticPattern? {
guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
return nil
}
return try? CHHapticPattern(contentsOf: url)
}
private func startPlayer(_ player: CHHapticPatternPlayer?) {
try? player?.start(atTime: CHHapticTimeImmediate)
}
}
关键点:
engine = try? CHHapticEngine()创建触感引擎。try? engine.start()启动引擎。initializeShieldHaptics()把护盾触感提前准备好,避免交互发生时才创建。createPatternFromAHAP("ShieldTransient")对应 session 里的ShieldTransient资源。engine.makePlayer(with: pattern)生成shieldPlayer。shield()是护盾变形实际发生的位置。startPlayer(shieldPlayer)先播放触感和音频。sphereView.layer.add(shieldAnimation, forKey: "Width")播放护盾视觉动画。
修改 AHAP:把喜欢的声音接到合适的触感上
(10:11)
Apple 展示了两个护盾资源。
ShieldTransient 的声音有“获得保护”的感觉,但触感由三个瞬态事件组成。声音是连续推进的,两者节奏不匹配。
ShieldContinuous 的触感是连续推进的,更适合“护盾生成”。但它自带的音频会衰减,效果也不合适。
最终做法很直接:打开 ShieldContinuous.ahap,找到音频事件,把文件名从 ShieldB.wav 改成 ShieldA.wav。触感保留连续模式,声音换成第一个资源里的声音。
{
"Event": {
"Time": 0,
"EventType": "AudioCustom",
"EventWaveformPath": "ShieldA.wav",
"EventParameters": [
{
"ParameterID": "AudioVolume",
"ParameterValue": 1.0
}
]
}
}
关键点:
EventType使用AudioCustom,表示这个事件播放自定义音频文件。EventWaveformPath指向要播放的.wav文件。- session 中的修改是把
ShieldB.wav换成ShieldA.wav。 AudioVolume用ParameterValue调整音量。- 这个改动发生在 AHAP 文件里,不需要改 Swift 播放逻辑。
(11:07)
资源修改完成后,Swift 代码只需要换文件名。初始化函数从 ShieldTransient 改为 ShieldContinuous。
func initializeShieldHaptics() {
guard let pattern = createPatternFromAHAP("ShieldContinuous") else {
return
}
shieldPlayer = try? engine.makePlayer(with: pattern)
}
关键点:
- 函数名没有变化,调用点也不需要变化。
ShieldContinuous是修改后的 AHAP 资源。engine.makePlayer(with:)仍然从 pattern 创建 player。- 护盾动画触发时,
shield()继续调用同一个shieldPlayer。
滚动纹理:用高级播放器解决两秒后停止
(12:15)
滚动纹理的第一版实现有一个问题。背景纹理打开后,球滚动时有触感,但几秒后触感消失。
原因很具体:纹理 AHAP 文件只有 2 秒触感内容。
Apple 的修复方法是把纹理播放器换成 CHHapticAdvancedPatternPlayer。高级播放器提供暂停、恢复、回调等能力。HapticRicochet 只用它的循环播放能力。
import CoreHaptics
final class TextureHaptics {
private let engine: CHHapticEngine
private var texturePlayer: CHHapticAdvancedPatternPlayer?
init(engine: CHHapticEngine) {
self.engine = engine
}
func initializeTextureHaptics() {
guard let url = Bundle.main.url(forResource: "Texture", withExtension: "ahap"),
let pattern = try? CHHapticPattern(contentsOf: url) else {
return
}
texturePlayer = try? engine.makeAdvancedPlayer(with: pattern)
texturePlayer?.loopEnabled = true
}
func startTexture() {
try? texturePlayer?.start(atTime: CHHapticTimeImmediate)
}
}
关键点:
texturePlayer的类型是CHHapticAdvancedPatternPlayer。CHHapticPattern(contentsOf:)仍然从同一个纹理 AHAP 文件创建 pattern。engine.makeAdvancedPlayer(with:)创建高级播放器。texturePlayer?.loopEnabled = true开启循环。start(atTime:)的播放入口保持一致。
滚动纹理:触感强度跟随球速更新
(12:26)
session 里提到,updateTexturePlayer 会在纹理激活时每一帧调用。它根据球的速度更新触感强度。
这里的设计原则是 causality(因果)。用户看到球在纹理背景上滚动,手里感到纹理。球越快,反馈越明显。反馈来源变得清楚。
import CoreHaptics
func updateTexturePlayer(ballSpeed: Float, texturePlayer: CHHapticAdvancedPatternPlayer?) {
let normalizedSpeed = max(0, min(ballSpeed, 1))
let intensity = CHHapticDynamicParameter(
parameterID: .hapticIntensityControl,
value: normalizedSpeed,
relativeTime: 0
)
try? texturePlayer?.sendParameters([intensity], atTime: CHHapticTimeImmediate)
}
关键点:
ballSpeed表示当前球速,session 说明触感会基于球速更新。max(0, min(ballSpeed, 1))把输入限制在 0 到 1。CHHapticDynamicParameter用来在播放中调整参数。.hapticIntensityControl调整触感强度。relativeTime: 0表示参数立即生效。sendParameters(_:atTime:)把新的强度发送给正在播放的高级播放器。
视觉纹理也要匹配触感密度
(14:04)
修复循环播放后,还剩一个设计问题。
触感模式很密,2 秒里接近 100 个条目。视觉背景却很粗,屏幕上只有少量圆点。手感和画面密度不一致。
Apple 的处理是把背景图资源换成更细的版本。触感模式不变,视觉纹理变密,球滚过背景时,手感和画面更接近。
import UIKit
final class TextureBackgroundView: UIView {
private let backgroundImageView = UIImageView()
func useFineTexture() {
backgroundImageView.image = UIImage(named: "BackgroundTextureFine")
}
}
关键点:
backgroundImageView负责显示纹理背景。UIImage(named:)从资源中读取图片。BackgroundTextureFine对应 session 中提到的 Fine 版本资源。- 这个改动解决的是感官一致性:视觉圆点密度要接近触感事件密度。
核心启发
1. 做一个“物体碰撞”反馈层
- 做什么:给球、卡片、棋子这类可移动对象加入撞边、落下、吸附的声音和触感。
- 为什么值得做:session 用球撞墙说明了 causality。反馈要让用户知道哪个动作触发了它。
- 怎么开始:为每种碰撞准备一个 AHAP 文件,用
CHHapticPattern(contentsOf:)读取,再用engine.makePlayer(with:)创建播放器,在碰撞检测回调里播放。
2. 做一个“状态升级”动画反馈
- 做什么:在用户完成升级、解锁、装备护盾时,同时播放动画、音频和触感。
- 为什么值得做:护盾示例证明,状态变化适合用连续触感表达,让用户感到对象进入了新状态。
- 怎么开始:先做 500 毫秒左右的视觉动画,再用 AHAP 试出一个同样时长的连续触感;如果音频不合适,直接在 AHAP 里替换
AudioCustom的文件名。
3. 做一个“材质纹理”开关
- 做什么:让用户切换纸张、金属、网格、粗糙地面等背景材质,拖动物体时感到不同纹理。
- 为什么值得做:滚动纹理示例把视觉背景和连续触感绑定在一起,用户能理解触感来自屏幕上的纹理。
- 怎么开始:为每种材质准备一个 2 秒 AHAP,用
CHHapticAdvancedPatternPlayer播放,并设置loopEnabled = true。
4. 做一个“速度驱动”的触感系统
- 做什么:让滚动、滑动、拖拽的触感强度跟随速度变化。
- 为什么值得做:HapticRicochet 每一帧根据球速更新纹理触感,避免固定强度带来的假感。
- 怎么开始:计算对象速度,把速度归一化到 0 到 1,再用
CHHapticDynamicParameter和.hapticIntensityControl更新正在播放的高级播放器。
5. 做一个 AHAP 调试工作流
- 做什么:把触感资源当作设计资产管理,和图片、音频一起迭代。
- 为什么值得做:session 展示了 macOS 12 的 Quick Look Visualizer。选中
.ahap文件按空格,就能查看触感模式。 - 怎么开始:把
.ahap、.wav、.png放到资源目录。设计阶段用 Quick Look 检查 transient 和 continuous 事件,工程阶段只负责加载和播放。
关联 Session
- Discover geometry-aware audio with the Physical Audio Spatialization Engine (PHASE) — 用 PHASE 创建与几何场景同步的交互音频。
- Immerse your app in Spatial Audio — 为媒体播放加入 Spatial Audio,并处理不同设备能力。
- Dive into RealityKit 2 — 讲 RealityKit 2 的动画、角色控制和音频相关增强。
- What’s new in AVFoundation — 了解 Apple 媒体框架在读取、合成和字幕上的更新。
评论
GitHub Issues · utterances