Communitygithub.com

dembsky/PropMotion

Claude Code 技能:让你的 SwiftUI 应用中一个 3D 对象动起来。SceneKit 舞台、编排好的动作、手写物理引擎和真实阴影。

PropMotion 是什么?

PropMotion 是一个专注于在 SwiftUI 应用中实现单个 3D 对象精细表演的技能。它提供了一套经过生产验证的通用模式,用于在透明的 SceneKit 舞台上展示精美的产品动画,包括入场、退场、抛掷、阴影、触觉反馈等,并揭示了开发中常见的陷阱。该技能适用于 Claude Code、Cursor 等支持 SKILL.md 的 AI 编码助手,通过提供详细的代码示例和参考文档,帮助开发者快速集成 SceneKit 到 SwiftUI 项目中。核心原理是使用 UIViewRepresentable 包装透明的 SCNView,通过分解节点层次(每个运动关注点一个节点)和烘焙的 CAKeyframeAnimation 来编排复杂动作,同时利用两键点火策略解决 Metal 管线编译导致的卡顿问题。

兼容平台✓Claude Code~Codex CLI~Cursor
npx skills add dembsky/PropMotion

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

PropMotion 是做什么的?

让你的 SwiftUI 应用中一个 3D 对象动起来。针对一个特定而常见的任务的生产模式:一个精美的产品演员在透明的 SceneKit 舞台上表演——入场、退场、抛掷、阴影、触觉反馈,以及那些会耗费数天的无声陷阱。

何时使用 SceneKit

要坦诚面对该框架的现状:

  • SceneKit 处于维护模式。对于需要大量 3D 能力(资产管线、USD/USDZ、AR、大型世界)的新应用,建议优先使用 RealityKit。
  • 在 SwiftUI 应用中实现装饰性 3D 演员时,SceneKit 仍然是最快的途径:一个透明的 SCNView 可以覆盖在任何 SwiftUI 布局之上,几何体着色器修饰符只是一个 MSL 字符串,CoreAnimation 互操作已经很成熟,这里的一切都运行在普通的 UIKit 视图上,无需任何会话设置。
  • 如果 3D 元素是一个带有编排动作的主角对象,那么这个技能提供的方法可以直接应用。如果它是一个完整的交互式世界,请停下来,优先考虑 RealityKit。

核心舞台配方

一个舞台包含:透明的 SCNView、一个 @MainActor 协调器(拥有场景图)、一个位于站立视线高度的摄像机、一个三灯组加一个专用阴影灯、一个接触光晕和一个阴影捕捉器。演员通过烘焙的关键帧动画进行表演。

struct ProductStage: UIViewRepresentable {
    let item: Item

    // 关键点必须仅在场景必须发生可见变化时更改。
    private var stateKey: String { "\(item.id)" }

    func makeCoordinator() -> Coordinator { Coordinator() }

    func makeUIView(context: Context) -> SCNView {
        let view = SCNView()
        view.backgroundColor = .clear   // 舞台覆盖在 SwiftUI 之上
        view.isOpaque = false
        view.antialiasingMode = .multisampling4X
        view.scene = context.coordinator.buildScene()
        view.pointOfView = context.coordinator.cameraNode
        context.coordinator.install(item)
        context.coordinator.markState(stateKey)
        // 第一帧预热,两键点火:在关键路径之外编译着色器,
        // 让演员在台侧待命,并将首次入场门控在最小延迟和准备完成两者之上。
        context.coordinator.parkOffstage()
        if let scene = view.scene {
            view.prepare([scene]) { _ in
                DispatchQueue.main.async { context.coordinator.markPipelinesWarm() }
            }
        }
        context.coordinator.scheduleFirstEntrance()
        return view
    }

    func updateUIView(_ view: SCNView, context: Context) {
        // 状态键 diff:SwiftUI 重新渲染绝不能重放入场。
        guard context.coordinator.stateKey != stateKey else { return }
        context.coordinator.markState(stateKey)
        context.coordinator.transition(to: item)
    }
}

各部分按构建顺序如下:

  1. 透明视图标志(backgroundColor = .clear,isOpaque = false)、MSAA 4x。宿主屏幕提供背景;场景没有盒子。
  2. 协调器拥有节点层次结构,每个运动关注点(移动、升降、偏航、旋转)分解为一个节点,因此独立的动画永远不会争夺单个变换。
  3. 摄像机:projectionDirection = .horizontal,使得演员的大小跟随舞台宽度;架高的位置带有轻微的下倾视角,像站立的观察者。保持静态;移动的摄像机会显得不自然。
  4. 灯光:暖色主光、冷色低补光、硬质轮廓光,环境光仅作为底线值,再加上一个专用的投射阴影的方向光,从演员的后上方照射,使其不会干扰可见的造型。
  5. 地面接触:一个柔和的暗色渐变平面(接触光晕)放在演员下方,加上一个不可见的 .shadowOnly 捕捉器平面用于真实阴影。
  6. 反射材质采样一个小的程序化环境贴图,而不是照片。保持摄像机后面的区域为暗色。
  7. 所有编舞都是烘焙的 CAKeyframeAnimation,由生成令牌保护,因此任何新的节拍都可以取代待定工作。

带有代码的完整细节:references/stage-recipe.md

最耗时的陷阱

陷阱修复方法
开启 MSAA 时,延迟阴影从不渲染,且没有控制台错误使用 shadowMode = .forward 加上 .shadowOnly 捕捉器。如果怀疑阴影缺失,首先给场景加一个可见的灰色 Lambert 地板来调试。
全新 SCNView 的第一帧在你的入场动画中间编译 Metal 管线在后台使用 prepare([scene]),让演员在台侧待命,延迟首次入场。永远不要在首帧上开始动画。
fillMode = .forwards + isRemovedOnCompletion = false 会固定住呈现状态,并且移除是按对象进行的一个中心化的 clearAnimations,清扫节点、每个子几何体、灯光和正在运行的 actions,并从每个入口点调用。
正面朝向的真实金属呈现为灰色或黑色空洞正对观察者的镜子反射的是摄像机后方的环境区域。保留已批准的涂装基色,并添加一个带有自己反射映射图的薄薄的附加层。
切换 castsShadow 会在一个帧中弹出一个模糊的半影;shadowBias 和灯光的 categoryBitMask 对于正向方向阴影被忽略永久保持 castsShadow 为开启状态,并动画 shadowColor 的 alpha,与运动同步。
固定的预热延迟在冷设备和模拟器上仍然卡顿;粒子效果的第一帧会编译自己的管线,并在其触发时恰好掉帧两键点火:将入场门控在最小延迟和 prepare 的完成处理程序两者之上。使用一个 alpha 为 0、但标志与真实效果匹配的爆发来预热粒子管线。
默认的 UIGraphicsImageRenderer 格式继承屏幕比例,使每个生成的纹理像素膨胀 9 倍——对于实时重新渲染的纹理(每次击键的雕刻)是致命的将渲染器格式的比例固定为 1,并将画布大小调整为演员的屏幕投影;将多输入重定向合并为每个更新通道的一次渲染。
从照片重现真实物体的运动会得到自信的装配,但会侧向失败——每次修复都会揭示一个新的错误静态照片承载的是姿态,而不是路径或机制;最终姿态拟合不能决定轨迹。对路径进行建模:一个带有直接姿态控制的校准装配,由所有者在真实物体上对着关键帧。
一个关键帧动画在其关键帧处有节奏地卡顿,并且所有渲染和时序修复都无效卡顿被烘焙到了曲线中:不均匀间隔关键帧上的统一 Catmull-Rom 基函数会在每个节点处改变速度。使用基于距离的相位上的跨度加权 Hermite 切线(或自然三次样条)进行插值,并通过数值连续性检查进行门控。
从更大模型中切出的子网格的测量结果就好像它是整个物体,且没有任何错误切割仅修剪了索引缓冲区;顶点缓冲区仍然保存着原始模型的每个顶点。只测量子网格索引引用的顶点。
分配给 scene.lightingEnvironment 的方形图像被静默忽略——零反射,每个镜面材质渲染为黑色,没有控制台输出以识别的立方体贴图布局绘制环境贴图,最简单的是一张 2:1 的球形画布(1024x512)。只有 material.reflective 接受方形球面贴图。

参考地图

  • references/stage-recipe.md:完整的舞台、视图设置、协调器、节点层次结构、摄像机、灯光组、接触光晕和捕捉器、程序化环境贴图和纹理、舞台上的冲击粒子、舞台演员上的着色器修饰符(线性空间均匀陷阱)、保持舞台的 SwiftUI 标识(重新挂载和更新风暴陷阱)。
  • references/first-frame-and-warmup.md:首次绘制时的 Metal 管线编译卡顿及两种解决方法:使用延迟入场的预热(升级到基于 prepare 完成的两键点火),或保持场景挂载并预热并重新定位;粒子管线预热;使用 signposts 和设备卡顿追踪证明解决方法有效。
  • references/shadows-and-lights.md:每个无声的阴影陷阱、可靠的 forward + shadowOnly 组合、动画阴影可见性、中性光预算、正面朝向的金属。
  • references/baked-animation.md:为什么烘焙关键帧优于计时器、在烘焙前设计多相位节拍的提示表、接缝分类(C1 vs 接触脉冲)、设计重量、密集采样、清理记账、生成令牌、在动画中间窃取节点、滚动而不滑动、沿地板路径滚动(转向、屏幕空间阶段、调试轨迹)、变换之外的通道(变形器权重、镜头值、着色器统一变量)、作为创作材质的弹簧、一个属性一个所有者。
  • references/motion-from-reference.md:重现真实物体的运动——静态照片和视频能告诉和不能告诉你什么、建模路径而非机制、校准装配(所有者摆出演员姿势并保存关键帧)、距离参数化和手保存姿势的跨度加权插值、统一 Catmull-Rom 陷阱、数值连续性门控、无缝余弦状态循环及从当前相位退出、测量修剪后的子网格。
  • references/physics-without-engine.md:手写的固定步长积分器,烘焙到关键帧、墙壁和地板作为纯数字、接触驱动的触觉反馈、边缘枢轴倾倒,以及为什么这比 SCNPhysics 更适合编排场景。
  • references/gestures-and-haptics.md:无需命中测试的拖拽抓取、抓取时的软夹持、释放速度、冲击触觉、在实时手指下存活的脚本节拍(有界重试、令牌轮询、隐藏演员手势守卫、窃取关闭持有契约)、减少运动作为分类法、对不可见舞台的 VoiceOver 访问、与 SwiftUI 手势共存。
  • references/sequencing-stages.md:将多个舞台作为一部电影来指导——一个带有节拍作为数据的主时钟、壁钟漂移陷阱、在退场清除后切到运动、为预热预挂载下一个舞台、一个寿命超过其自身剪切点的舞台(幽灵烟雾)、基于用户交互重新设定时间线、录制引导、逐帧验证剪切。
  • references/modeling-actors.md:从基本体构建可信的主角对象——在凭眼力估计之前的真实世界比例、用于凹面的环形、管状加环状轮廓、径向图案清晰度、用于凹面的每个实例倾斜、开放间隙、作为几何体的浮雕特征(绝不使用绘制)、暗色舞台上的缎面金属反照率、门控作为文件到达的演员。
  • references/camera-choreography.md:摄像机作为表演者——轨道装备、烘焙的揭示飞行、对焦随轨道移动、带惯性和自动归位的拖拽轨道、作为接地感的 SCNFloor 反射、聚光灯下的遮光板阶段、用于跟踪装备的约束(以及为什么它们从不放在演员身上)。
  • references/instancing-and-swarms.md:同时多个演员——一个用于群体的几何体、基于槽的堆叠代替物理、从碰撞点逆向求解的抛物线、混合到休息姿势的翻滚、种子随机性、通过调度交错、预算说明。
  • references/multi-actor-physics.md:一个烘焙模拟中的多个演员——一个时钟上的 N 个状态向量、成对碰撞(分离,然后在接近时交换)、冻结世界的抓取、命中测试手指选择了哪个演员、用于挤压和触觉的每个演员接触列表。

审核清单

在发布舞台之前,请验证:

  • SCNView 上设置了 backgroundColor = .clear 和 isOpaque = false;舞台合成没有可见的边框或接缝。
  • 没有动画在视图的第一帧开始;prepare 已运行,且首次入场已延迟,或者场景保持挂载并预热。
  • 阴影使用正向模式,带有 .shadowOnly 捕捉器;没有依赖延迟阴影、shadowBias 或灯光 categoryBitMask 门控。
  • 带有 Alpha 纹理的平面(标签、贴花)具有 castsShadow = false,否则它们会投射方形阴影。
  • 阴影可见性变化通过渐变 shadowColor 的 alpha 实现;castsShadow 在运行时从不切换。
  • 如果场景必须颜色忠实地渲染纹理,环境光加上方向光乘以 cos(入射角) 对于平坦表面应总和为中性 (1000);没有裁剪过的浅灰色。
  • lightingEnvironment(或 background)上的任何图像都使用已识别的立方体贴图布局——2:1 球形、6:1 或 1:6 序列,或六张图像;那里的方形图像会被静默忽略,导致反射材质渲染为黑色。
  • 每个异步延续(延迟入场、计划触觉、链式过渡)都捕获一个生成令牌,并在采取行动之前重新检查它。
  • 任何窃取动画节点的代码在调用 removeAllAnimations 之前,会快照 .presentation 值到模型中。
  • 烘焙动画在 addAnimation 之前将模型设置为最终姿势;依赖 fillMode = .forwards 且没有清理扫描。
  • 多相位节拍在姿势函数上方有一个提示表注释,与相位边界结构匹配;相位和提示时间只存在于该一个结构中,从不作为样本循环或计划触觉中的字面量。每个接缝都被分类:设计接缝是 C1,脉冲接缝只出现在接触事件中,并且每个都有一个提示。
  • 滚动对象将旋转与距离除以半径相关联;没有滑动。
  • 从真实物体复制的运动是通过校准装配(直接姿势控制、保存的姿势、所有者的视角)作为关键帧创作的。

相关技能