Code Repo: github.com/xuchi16/vis...
Project Path: github.com/xuchi16/vis...
概述
本文主要包含以下内容:
- Shader 的基本概念
- visionOS 中的 Material,Shader,Shader Graph 等基本概念
- 利用 SwiftUI 代码控制 Shader 的特定节点
- 一个基本的利用代码控制 Surface Shader 的示例
最终效果:
基本概念
Shader(着色器)是一种用于图形处理的程序,在 3D 模型渲染中,用于确定最终像素的颜色、亮度、对比度等属性。Shader 程序主要运行在 GPU 上,可以高效地处理图形和图像数据。
Material 用来表示 3D 物体的材料,在 visionOS 中主要有 2 种材质:
- PBR Material(Physically-Based Rendering) :基于物理渲染的材料
- Custom Material:自定义材料
其中,自定义材料可以让开发者绑定不同的 Shader,从而实现更为丰富的视觉效果。
Shader Graph 是 visionOS 中表示 Shader 的对象。在 Shader Graph 中,visionOS 提供了丰富的 Shader Node 用于构建不同的 Shader。其中还有部分 Node 可以通过程序进行控制。
Shader Node 主要分为 2 类,均可在 Shader Graph 中进行操作:
-
Surface shader: 主要用于控制 PBR 属性
-
Geometry shader: 主要用于控制几何属性
目前 visionOS 提供了 186 个 Shader Node。
- GitHub: vision-os-workshop shader document
- 在线版本:RealityKit Shaders
基本实现
Shader Graph
-
创建一个基本的 visionOS 项目,在 RealityKitContent 下打开 Package.realitycomposerpro
-
创建一种新的 Custom Material。打开底部的"Shader Graph"可以看到最初包含 2 个节点:
- PreviewSurface:MaterialX 版本的 USD Preview Surface
- Outputs:最终的输出节点,可以看到前置接收 2 个输入,分别对应上述的 Surface 和 Geometry 两类 Shader Node
本文希望实现一种类似上下滑动的窗帘的贴图效果:有前景、后景 2 幅图,用户可以通过一个滑块控制,随着滑块的滑动,图 1 逐渐消失变成图 2。这一效果应用在昼夜变化、历史演变等前后对比效果。
- 创建 2 个立方体,并且在右侧的 Inspector 中将 Material Binding 指定为刚刚新建的材料
- 编辑 Shader Graph
- Position 部分:Position 节点表示当前正在进行渲染的点的位置。这里我们希望滑动的效果是从上到下,因此需要在 Position 节点后接一个 Separate3 节点,用来分离坐标,并且只取其中的 y 坐标。
- Criteria 部分:用来控制两幅图交界的位置,y 坐标大于特定值展示一幅图,否则展示另一幅图。输入采用了 Float 节点,用于控制分界值,输入的值用 0-100 的百分比表示。另外用 Range 节点做了一层映射,将百分比转换成实际的坐标值。这里有一个关键步骤,如果想要后续通过代码控制 Float 节点数值,需要将该节点"Promote",相应地其颜色也会从淡紫色变为淡蓝色。
- Image 部分:加入 2 个 Image 节点,分别表示 2 幅图。将需要展示的图片导入 XCode,选择 Image 节点的 Filename 即可对应上相应的图片。
- IfGreaterOrEqual 节点:串联上前面 3 个部分,对比渲染点的 y 坐标(Position 部分)和分界线(Criteria 部分),并选择其中一幅图作为渲染结果。
完成上述步骤后,我们只需要在输入的 Float 节点"ScrollPercentage"中选择不同的输入值,就能实现基本的按照分界线渲染贴图的效果了。
代码逻辑控制
在实现通过 Float 节点控制根据 y 坐标渲染不同图片的效果后,需要通过 SwiftUI 控件控制 Float 的值,从而达到实时变化的效果。
- 在
ContentView
中添加 Slider,并且将控件的数值与sliderValue
绑定。
swift
@State private var sliderValue = 0.0
// ...
var body: some View {
VStack {
Text("Shaders")
.font(.title)
Toggle("Show Immersive Space", isOn: $showImmersiveSpace)
.toggleStyle(.button)
.padding(.top, 50)
HStack {
Spacer()
Text("Slider: ")
Slider(value: $sliderValue, in: 0...100)
Spacer()
}
.padding(.top, 20)
}
}
- 定义
ViewModel
存储 Slider 的数值状态以及需要修改的实体对象,同时提供一个更新方法,当滑块数值变化时候调用更新方法。
swift
@Observable
class ViewModel {
var percentage: Double = 0
var boxes: [ModelEntity] = []
}
- 在
ImmersiveView
中加载场景,并且根据名称找到需要渲染的实体,将其存储到ViewModel
中。
swift
struct ImmersiveView: View {
@Environment(ViewModel.self) var model
@State private var sliderValue: Float = 0
var body: some View {
RealityView { content in
// Add the initial RealityKit content
if let scene = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
if let box1 = scene.findEntity(named: "Box1") as? ModelEntity {
model.boxes.append(box1)
}
if let box2 = scene.findEntity(named: "Box2") as? ModelEntity {
model.boxes.append(box2)
}
model.update()
content.add(scene)
}
}
}
}
- 在
ViewModel
中提供更新方法,根据其中存储的滑块数值,更新存储的对象 Material。
swift
@Observable
class ViewModel {
var percentage: Double = 0
var boxes: [ModelEntity] = []
func update() {
for box in boxes {
print(box.name)
if var boxMaterial = box.model?.materials.first as? ShaderGraphMaterial {
try? boxMaterial.setParameter(name: "ScrollPercentage", value: .float(Float(percentage)))
box.model?.materials = [boxMaterial]
}
}
}
}
- 在此前的
ContentView
中,注册事件,当滑块初次加载以及发生变化时,将数值传递给ViewModel
,并且调用更新方法,更新 shader。
swift
@State private var sliderValue = 0.0
@Environment(ViewModel.self) var model
// ...
var body: some View {
VStack {
Text("Shaders")
.font(.title)
Toggle("Show Immersive Space", isOn: $showImmersiveSpace)
.toggleStyle(.button)
.padding(.top, 50)
HStack {
Spacer()
Text("Slider: ")
Slider(value: $sliderValue, in: 0...100)
Spacer()
}
.padding(.top, 20)
}
.padding()
.onChange(of: showImmersiveSpace) { _, newValue in
Task {
if newValue {
switch await openImmersiveSpace(id: "ImmersiveSpace") {
case .opened:
immersiveSpaceIsShown = true
case .error, .userCancelled:
fallthrough
@unknown default:
immersiveSpaceIsShown = false
showImmersiveSpace = false
}
} else if immersiveSpaceIsShown {
await dismissImmersiveSpace()
immersiveSpaceIsShown = false
}
}
}
.onChange(of: sliderValue) { _, newValue in
model.percentage = newValue
update()
}
.onAppear() {
update()
}
}
这样就能达到开头展示的最终效果。