GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜
はしがき
SwiftUIでSpriteKitのSKShaderを使って遊んでみようというテーマの記事の2回目です。
第1回の記事 では、SpriteKitやSKShaderの概要と、SwiftUIでそれらを表示するための最小限のコードを紹介しました。
今回は SKShaderの座標 についてとりあげます。
GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (1)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜(本記事)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (4) 〜経過時間とマウス座標〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (5) 〜テクスチャと色 前編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (6) 〜テクスチャと色 後編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (7) 〜ノード〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (8) 〜3D〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜
必要な前提知識
この記事では、以下に該当するようなプログラマーを想定読者としています。
- Swiftの基本文法をおおむね理解している
- SwiftUIの基本的な書きかたを知っている
- フラグメントシェーダー(GLSL, HLSL, etc.)のコードを多少でも書いて動かしてみた経験がある
SwiftUIを使っていてシェーダーにも興味があるけれど、SpriteKitやSKShaderにはなじみがない、ぐらいのレベル感です。
シェーダーの入門知識の説明は省きますので、たとえばGLSLの変数の種類や型名(uniform, float, vec4, ...)、関数名(length, mix, ...)、スウィズル演算子や座標の正規化といった用語が前置きなしに出てきます。
環境
以下の環境で動作確認を行っています。
- Xcode 15.2 (15C500b)
- iOS Deployment Target 15.0
- Apple Swift version 5.9.2 (swiftlang-5.9.2.2.56 clang-1500.1.0.2.5)
- Swift Playgrounds 4.5 (*)
- macOS Venture 13.6.6
- MacBook Air M2 2022
* XcodeのPlayground(PlaygroundSupport)では動作しません
貼付しているスクリーンショットはシミュレータの iPhone 13(iOS 15.5)で撮影したものをベースにしています。
SKShader の座標
SKShader で座標を取得できる変数の種類
シェーダーを書いていくうえでまず理解しておかなければならないのは 座標 についてでしょう。
SKShaderには座標を取得できる変数が2種類あります。
gl_FragCoord
と v_tex_coord
です。
gl_FragCoord 変数
gl_FragCoord
変数では、OpenGL ES 2.0のGLSLの組み込み変数と同じく ピクセル座標 を取得できます。
ただし、SKShaderで gl_FragCoord
変数を使う際の注意点が2つあります。
注意点1・原点
座標の原点 が シーンの左上 になっています。OpenGLの標準仕様では左下が原点なので、Y軸が反転して 上下が逆 になります。
なぜSKShaderでは左上が原点なのかというと、レンダラーであるMetalの座標系がそうなっているからだと考えられます。 前回の記事 でも書いたとおり、内部ではGLSLのコードをMSLに変換してMetalレンダラーで描画しています。この変換の際に、座標の右手系 ⇔ 左手系もいい感じに処理してくれればありがたかったのですが、どうやらそうはなっておらず、左手系の座標でGLSLのコードを扱う挙動になっているのでしょう。
このため、左下原点の環境用に作られたGLSLのコードを移植するときは、 Y軸を反転させないと元の出力を再現できません 。
念のため、座標の範囲が シーン に対するものであることも覚えておいてください。原点の位置はデバイスの画面の左上ではなく、SKShaderを適用するノードの左上でもありません。シーンの左上が基準になっています。
注意点2・単位
サイズの単位が ピクセル です。これは標準仕様と同じですが、どうしてピクセルであることに気をつけなければならないのかというと、SwiftUIやSpriteKitのコードでframeサイズを指定するときに使う単位が ポイント になっているからです。
前回の記事のサンプルコードでは、幅300ポイント×高さ250ポイント のframeサイズのシーンを作成しました。
SpriteView(scene: self.currentScene)
.frame(width: 300, height: 250)
前回の記事のサンプルコード全体
import SwiftUI
import SpriteKit
struct ContentView: View {
var currentScene: SKScene {
let scene = MySKScene()
scene.scaleMode = .resizeFill
return scene
}
var body: some View {
ZStack {
Color(white: 0.8)
SpriteView(scene: self.currentScene)
.frame(width: 300, height: 250)
}
}
}
class MySKScene: SKScene {
override func didMove(to view: SKView) {
let blue = UIColor(red: 0.29, green: 0.59, blue: 0.78, alpha: 1.0)
let green = UIColor(red: 0.64, green: 0.81, blue: 0.46, alpha: 1.0)
self.backgroundColor = blue
self.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let node = SKSpriteNode()
node.color = green
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
gl_FragColor = yellow;
}
"""
)
node.shader = shader
self.addChild(node)
}
}
シェーダー側で解像度を踏まえた計算をするためにシーンのサイズを扱いたいときに、 vec2 resolution = vec2(300.0, 250.0);
としてしまうと、Retinaディスプレイでは 1 point = 1 pixel ではない ので計算が合わなくなってしまいます。ポイントサイズにRetinaの倍率を掛けて、ピクセルサイズを算出する必要があるのです。
Retinaの倍率はデバイスによって異なり、その値は UITraitCollection.current.displayScale プロパティで取得できます。
座標の正規化
注意点1・2を踏まえて、座標の正規化を行ってみます。
※下記コードは一例です。コード中に出てくるuniform変数の渡しかたについては、次回の記事 で解説します。
// 〜略〜
let shader = SKShader(
source: """
void main() {
- vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
- gl_FragColor = yellow;
+ // 短辺に合わせて座標を正規化します
+ vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
+ // Y座標を上下反転させます
+ st.y *= -1.0;
+ // X座標の値を赤色、Y座標の値を緑色で出力します
+ gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
"""
)
+ // シーンの解像度の値をuniform変数としてシェーダーに渡します
+ shader.addUniform(
+ SKUniform(
+ // シェーダー内で使用する変数名を指定します
+ name: "u_resolution",
+ // シーンの解像度(ピクセル値)を計算して渡します
+ vectorFloat2: vector_float2(
+ // シーンの幅にRetinaの倍率を掛けます
+ Float(self.frame.width * UITraitCollection.current.displayScale),
+ // シーンの高さにRetinaの倍率を掛けます
+ Float(self.frame.height * UITraitCollection.current.displayScale)
+ )
+ )
+ )
node.shader = shader
self.addChild(node)
}
}
サンプルコード全体
import SwiftUI
import SpriteKit
struct ContentView: View {
var currentScene: SKScene {
let scene = MySKScene()
scene.scaleMode = .resizeFill
return scene
}
var body: some View {
ZStack {
Color(white: 0.8)
SpriteView(scene: self.currentScene)
.frame(width: 300, height: 250)
}
}
}
class MySKScene: SKScene {
override func didMove(to view: SKView) {
let blue = UIColor(red: 0.29, green: 0.59, blue: 0.78, alpha: 1.0)
self.backgroundColor = blue
self.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let node = SKSpriteNode()
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
st.y *= -1.0;
gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
"""
)
shader.addUniform(
SKUniform(
name: "u_resolution",
vectorFloat2: vector_float2(
Float(self.frame.width * UITraitCollection.current.displayScale),
Float(self.frame.height * UITraitCollection.current.displayScale)
)
)
)
node.shader = shader
self.addChild(node)
}
}
座標が正規化されて、シーンの中央が原点 (0.0, 0.0)、右上が (1.2, 1.0) になりました。
このスクリーンショットだけでは座標がわかりづらいと思うので、こんどは中心から円を描いてみます。
// 〜略〜
let shader = SKShader(
source: """
void main() {
vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
st.y *= -1.0;
- gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
+ // 原点からの距離を計算します
+ float l = length(st);
+ // 色を用意します
+ vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
+ // 原点からの距離を黄色で出力します
+ gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
}
"""
)
shader.addUniform(
SKUniform(
name: "u_resolution",
vectorFloat2: vector_float2(
Float(self.frame.width * UITraitCollection.current.displayScale),
Float(self.frame.height * UITraitCollection.current.displayScale)
)
)
)
node.shader = shader
self.addChild(node)
}
}
中央の座標が (0.0, 0.0) になっていることが、よりわかりやすくなったのではないかと思います。
しかし、ノードの高さ(200ポイント)がシーンの高さ(250ポイント)よりも短いので、円の上下がはみ出してしまっていますね。ノードのサイズをシーンと同じにしてみるとどうなるでしょうか。
// 〜略〜
- node.size = CGSize(width: 250, height: 200)
+ // ノードのサイズをシーンのサイズと同じにします
+ node.size = self.frame.size
let shader = SKShader(
source: """
void main() {
vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
st.y *= -1.0;
float l = length(st);
vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
}
"""
)
shader.addUniform(
SKUniform(
name: "u_resolution",
vectorFloat2: vector_float2(
Float(self.frame.width * UITraitCollection.current.displayScale),
Float(self.frame.height * UITraitCollection.current.displayScale)
)
)
)
node.shader = shader
self.addChild(node)
}
}
円の上下がぴったり収まりました。
このとおり、 gl_FragCoord
変数では シーンの左上を原点 とした ピクセル座標 が取得できます。
gl_FragCoord変数のサンプルコード全体
import SwiftUI
import SpriteKit
struct ContentView: View {
var currentScene: SKScene {
let scene = MySKScene()
scene.scaleMode = .resizeFill
return scene
}
var body: some View {
ZStack {
Color(white: 0.8)
SpriteView(scene: self.currentScene)
.frame(width: 300, height: 250)
}
}
}
class MySKScene: SKScene {
override func didMove(to view: SKView) {
let blue = UIColor(red: 0.29, green: 0.59, blue: 0.78, alpha: 1.0)
self.backgroundColor = blue
self.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let node = SKSpriteNode()
node.size = self.frame.size
let shader = SKShader(
source: """
void main() {
vec2 st = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
st.y *= -1.0;
float l = length(st);
vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
}
"""
)
shader.addUniform(
SKUniform(
name: "u_resolution",
vectorFloat2: vector_float2(
Float(self.frame.width * UITraitCollection.current.displayScale),
Float(self.frame.height * UITraitCollection.current.displayScale)
)
)
)
node.shader = shader
self.addChild(node)
}
}
v_tex_coord 変数
v_tex_coord
変数では、ノードのテクスチャ座標を取得できます。 SpriteKitがvarying変数としてあらかじめ用意してくれている ので、特別な準備なしでシェーダーのコード内で使えます。
gl_FragCoord変数とは違って ノードの左下が原点 (0.0, 0.0) で、 右上が (1.0, 1.0) になるように正規化されています。
テクスチャに関してはまた別の記事で紹介するとして、まずは実際にシェーダーを描いて座標を確認してみましょう。
// 〜略〜
// ノードのサイズを250x200にします
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
// テクスチャ座標を取得します
vec2 st = v_tex_coord;
// X座標の値を赤色、Y座標の値を緑色で出力します
gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
"""
)
node.shader = shader
self.addChild(node)
}
}
サンプルコード全体
import SwiftUI
import SpriteKit
struct ContentView: View {
var currentScene: SKScene {
let scene = MySKScene()
scene.scaleMode = .resizeFill
return scene
}
var body: some View {
ZStack {
Color(white: 0.8)
SpriteView(scene: self.currentScene)
.frame(width: 300, height: 250)
}
}
}
class MySKScene: SKScene {
override func didMove(to view: SKView) {
let blue = UIColor(red: 0.29, green: 0.59, blue: 0.78, alpha: 1.0)
self.backgroundColor = blue
self.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let node = SKSpriteNode()
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
vec2 st = v_tex_coord;
gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
}
"""
)
node.shader = shader
self.addChild(node)
}
}
中央を原点にして円を描くと次のようになります。
// 〜略〜
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
- vec2 st = v_tex_coord;
- gl_FragColor = vec4(st.x, st.y, 0.0, 1.0);
+ // 右上が (1.0, 1.0) に正規化された状態を維持しつつ、原点を中央に移動させます
+ vec2 st = v_tex_coord * 2.0 - 1.0;
+ // 原点からの距離を計算します
+ float l = length(st);
+ // 色を用意します
+ vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
+ // 原点からの距離を黄色で出力します
+ gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
}
"""
)
node.shader = shader
self.addChild(node)
}
}
ノードの縦横の長さが違うので楕円になってしまいました。アスペクト比を考慮した正規化を行って、正円に整えましょう。
※下記コードは一例です。コード中に出てくるattribute変数の渡しかたについては、次回の記事 で解説します。
// 〜略〜
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
- vec2 st = v_tex_coord * 2.0 - 1.0;
+ // アスペクト比を考慮して短辺に合わせた正規化をしつつ、原点を中央に移動させます
+ vec2 st = (v_tex_coord * 2.0 - 1.0) * a_aspect_ratio;
float l = length(st);
vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
}
"""
)
+ // attribute変数をノードから受け取るための設定をシェーダーに追加します
+ shader.attributes = [
+ SKAttribute(
+ // シェーダー内で使用する変数名を指定します --- (1)
+ name: "a_aspect_ratio",
+ // 変数の型を指定します
+ type: .vectorFloat2
+ )
+ ]
+ // ノードのアスペクト比の値をattribute変数としてノードに設定します
+ node.setValue(
+ SKAttributeValue(
+ // ノードのアスペクト比を計算して渡します
+ vectorFloat2: vector_float2(
+ // ノードの幅:ノードの短辺の長さ の比率を計算します
+ Float(node.frame.width / min(node.frame.width, node.frame.height)),
+ // ノードの高さ:ノードの短辺の長さ の比率を計算します
+ Float(node.frame.height / min(node.frame.width, node.frame.height))
+ )
+ ),
+ // (1)で指定した変数名をここでも指定します
+ forAttribute: "a_aspect_ratio"
+ )
node.shader = shader
self.addChild(node)
}
}
正円がノードの中央に収まりました。
v_tex_coord変数のサンプルコード全体
import SwiftUI
import SpriteKit
struct ContentView: View {
var currentScene: SKScene {
let scene = MySKScene()
scene.scaleMode = .resizeFill
return scene
}
var body: some View {
ZStack {
Color(white: 0.8)
SpriteView(scene: self.currentScene)
.frame(width: 300, height: 250)
}
}
}
class MySKScene: SKScene {
override func didMove(to view: SKView) {
let blue = UIColor(red: 0.29, green: 0.59, blue: 0.78, alpha: 1.0)
self.backgroundColor = blue
self.anchorPoint = CGPoint(x: 0.5, y: 0.5)
let node = SKSpriteNode()
node.size = CGSize(width: 250, height: 200)
let shader = SKShader(
source: """
void main() {
vec2 st = (v_tex_coord * 2.0 - 1.0) * a_aspect_ratio;
float l = length(st);
vec4 yellow = vec4(0.99, 0.83, 0.46, 1.0);
gl_FragColor = mix(vec4(0.0), yellow, min(1.0, l));
}
"""
)
shader.attributes = [
SKAttribute(
name: "a_aspect_ratio",
type: .vectorFloat2
)
]
node.setValue(
SKAttributeValue(
vectorFloat2: vector_float2(
Float(node.frame.width / min(node.frame.width, node.frame.height)),
Float(node.frame.height / min(node.frame.width, node.frame.height))
)
),
forAttribute: "a_aspect_ratio"
)
node.shader = shader
self.addChild(node)
}
}
まとめ
今回はSKShaderの座標について紹介してきました。
各セクションでも強調しましたが、座標の原点が、
対象 | 原点 |
---|---|
SKShader の gl_FragCoord
|
シーンの左上 |
SKShader の v_tex_coord
|
ノードの左下 |
OpenGL の gl_FragCoord
|
左下 |
SpriteKit | 左下 |
SwiftUI / UIKit | 左上 |
という混乱しそうな状況になっているので、整理して覚えておくとよいでしょう。
次回は、サンプルコード中にも出てきた SKShaderに変数を渡す方法 についてとりあげます。よろしければ引き続きご覧ください。
次記事 → GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜
GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit シリーズの記事一覧
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (1)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (2) 〜座標〜(本記事)
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (3) 〜変数〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (4) 〜経過時間とマウス座標〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (5) 〜テクスチャと色 前編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (6) 〜テクスチャと色 後編〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (7) 〜ノード〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (8) 〜3D〜
- GLSLシェーダーで遊ぼう with SwiftUI × SpriteKit (9) 〜落穂拾い〜
Discussion