u_time の使い方に注意!
iOS 8 では SpriteKit が更に強化され、これでサードパーティ製のライブラリ(Cocos2D など)を導入しなくてもネイティブだけで Fragment Shader まで普通に使えるようになり、大変助かるのです。ところが、この Shader ですが、SpriteKit の問題なのかなんのかよくわかりませんが、物によっては長く放置してしまうとレンダリングが徐々におかしくなっていくこともしばしばあります。まあ結論から言うとこの章のタイトル通り、「u_time」の使い方には注意してほしいということですが、何の話なんかさっぱりわからない読者も居ると思いますのでとりあえず順を追ってまず SpriteKit でアプリを作るところから説明します。もうわかってるよとりあえず Shader をどう直せばいいのかが知りたい読者は直接「u_time を直す」の章へGO👉
ちなみに、「u_time を直す」までの部分は単純にこちらの動画をわかりやすく日本語に直しただけなので、英語でも問題ないよという方ならその動画でそのまま進めてみましょう!
SpriteKit を導入
それじゃあまず SpriteKit でのアプリの作り方から始めましょう。まず Xcode 開いて、新規プロジェクトつくって、プロジェクトテープレートはゲームにします
次に適当に名前つけて、Game Technology は SpriteKit にして、言語は Objective-C でもいいんですがとりあえずここは Swift にして、あとは作成
そうするととりあえず最初のテンプレートアプリが作成されます。いつもの Single View アプリよりだいぶコード量が増えているのがわかると思います
このテンプレートでは SKScene オブジェクトをインターフェイスファイルから作成されることになりますが、まあここではそれ使わないのでとりあえず削除。削除したそれに合わせて GameViewController
のコードも if let scene = GameScene
のブロック外して、普通に let scene = GameScene(size: CGSize(width: 1920, height: 1080))
にしましょう(サイズはまあ自由に自分の好きなサイズで作っちゃいましょう。ここは適当に 1080p にしただけです。
ここまで来たらあと GameViewController
クラスは特に弄らないので放っておきましょう。次は GameScene
クラスです。まずテンプレートではすでにいろんなことを設定しておいてくれました。
が、これらも特に使わないので中身全部削除して、適当に「Test.png」の名前で画像をプロジェクトに追加して、didMoveToView(view: SKView)
メソッドに下記のコードを追加します
// SKSpriteNode オブジェクトを作って中身をその Test.png の画像にします
let node = SKSpriteNode(imageNamed: "Test")
// SKScene の起点はど真ん中ではなく左下なので、それに合わせて node の基準点(アンカーポイント)もそれにします
node.anchorPoint = CGPoint(x: 0, y: 0)
// 画面に追加します
self.addChild(node)
ちなみに Test.png の画像は別になんでもいいんです。例えば筆者の場合は本当にただの真っ赤の画像でした
さあ起動してみましょう、起動するとこのような画面が表示されます
これで、SpriteKit で最初のアプリが作れました!
Shader を入れよう
まず Shader ってなに?という方もいると思いますが…まあぶっちゃけ筆者もそんなに詳しくないのでとりあえず Shader Language で書かれた画面処理のことって OK かな?んでそんな Shader 書いたこともないしわかんないよって方も大丈夫!だって筆者も書いたことないから(おい!)そしてそんな Shader 書けない人のために、便利なサイトが有るんですね、たとえばここ:ShaderToy。今日はとりあえずこの中の bubbly sines というものを作ってみましょう。PC で見るとページの右側に Shader のソースコードがありますので GLSL とかがわからなくても大丈夫だ問題ない!
それでは Xcode に戻って Shader のために新しいファイルを追加しましょう
ファイル名はなんでも OK(ここでは BubblySines.fsh にしています)ですが拡張子だけは .fsh が絶対です。(ちなみになぜ fsh かというとおそらく Fragment Shader から来てるかと思います)
そして先のページの右側からコードをそのままコピペして保存しましょう
できましたら次は GameScene
に戻って、self.addChild(node)
の前に Shader を入れます。とっても簡単です♪
// Shader を BubblySines.fsh ファイルから作ります
let bubblySines = SKShader(fileNamed: "BubblySines.fsh")
// node に先ほど作った Shader を追加します
node.shader = bubblySines
ね?簡単でしょ?これで Build and Run!って…あれ?画面変わらないぞ?さっきと同じ真っ赤なだけだぞ??とツッコむあなた、あなたは正しいです!なぜなら、Shader のコードをコピペだけでは使えませんでした\(^o^)/なんでや!なにが「GLSL とかがわからなくても大丈夫だ問題ない」だ!結局コピペじゃ使えねぇじゃねか!!って思うかもしれませんが、まあまあ大丈夫そんなに難しいことではありませんからもう少し付き合ってくださいな
SpriteKit 仕様に合わせて Shader コードを修正
まず、SpriteKit で Shader は #define
は使えませんので、これらを変数宣言に直します。
次に、void mainImage
関数もこんな複雑な関数は使えないので、簡潔に void main()
に直します。
次は定義されていない変数としてソースコードに iResolution
と iGlobalTime
がありますが、これらは要するに Shader のサイズと現在の時間を取得しているので、これらは公式資料を漁って見ればわかりますがそれぞれ u_sprite_size
と u_time
としてすぐに使えますので置き換えます。
最後は fragCoord
と fragColor
はそれぞれ gl_FragCoord
と gl_FragColor
として定義されているのでこれも置き換えます。というわけで最終的に出来上がったコードは
float M_PI = 3.141592653589793;
float M_2PI = 6.283185307179586;
vec3 c1a = vec3(0.0, 0.0, 0.0);
vec3 c1b = vec3(0.9, 0.0, 0.4);
vec3 c2a = vec3(0.0, 0.5, 0.9);
vec3 c2b = vec3(0.0, 0.0, 0.0);
void main()
{
vec2 p = 2.0*(0.5 * u_sprite_size.xy - gl_FragCoord.xy) / u_sprite_size.xx;
float angle = atan(p.y, p.x);
float turn = (angle + M_PI) / M_2PI;
float radius = sqrt(p.x*p.x + p.y*p.y);
float sine_kf = 19.0;//9.0 * sin(0.1*u_time);
float ka_wave_rate = 0.94;
float ka_wave = sin(ka_wave_rate*u_time);
float sine_ka = 0.35 * ka_wave;
float sine2_ka = 0.47 * sin(0.87*u_time);
float turn_t = turn + -0.0*u_time + sine_ka*sin(sine_kf*radius) + sine2_ka*sin(8.0 * angle);
bool turn_bit = mod(10.0*turn_t, 2.0) < 1.0;
float blend_k = pow((ka_wave + 1.0) * 0.5, 1.0);
vec3 c;
if(turn_bit) {
c = blend_k * c1a + (1.0 -blend_k) * c1b;
} else {
c = blend_k * c2a + (1.0 -blend_k) * c2b;
}
c *= 1.0 + 1.0*radius;
gl_FragColor = vec4(c, 1.0);
}
となります。これでもう一度 Build and Run してみましょう!画面がちゃんと変わるはずです!
問題点
実はこのコード、シミュレーターなら特に問題ないっぽいのですが、実機では最初のうちは問題なく動きますけどそのままずっと放置すると動きが変になってくるのです(筆者の iPhone 5s と iPhone 6 は両方同じ現象です。OS は iOS 8.3 です)。例えばこの動画のように行って戻ってみたいな感じになってしまいます。わかりづらかったら10分ほど放置してみましょう、違いが明らかに来るはずです
ちなみにこのシェーダーではこうなりますが、物によっては FPS が極端に落ちたように見えたりします。FPS 自体は60のままでかわらないのですが…
そしてなぜこうなってしまうかというと、冒頭で言いました通り、(理由はわかりませんが)u_time のせいなのです。
u_time を直す
先ほどの Shader のコードへ戻ります。コードを見なおしてみましょう、ここで u_time
を呼び出しているのは3箇所くらいあります。具体的どういう動きしているのかはわかりませんがもしかするとそれぞれ呼び出しの時にタイムラグが発生してしまうのではないかと筆者が勝手に疑います。というわけでそのまま u_time
を何度も呼び出すのではなく、一回だけ呼び出して違う変数に格納してみましょう。これで上記のコードがこのようになります:
float M_PI = 3.141592653589793;
float M_2PI = 6.283185307179586;
vec3 c1a = vec3(0.0, 0.0, 0.0);
vec3 c1b = vec3(0.9, 0.0, 0.4);
vec3 c2a = vec3(0.0, 0.5, 0.9);
vec3 c2b = vec3(0.0, 0.0, 0.0);
void main()
{
// u_time の値を time に格納します。
float time = u_time;
vec2 p = 2.0*(0.5 * u_sprite_size.xy - gl_FragCoord.xy) / u_sprite_size.xx;
float angle = atan(p.y, p.x);
float turn = (angle + M_PI) / M_2PI;
float radius = sqrt(p.x*p.x + p.y*p.y);
float sine_kf = 19.0;//9.0 * sin(0.1*time);
float ka_wave_rate = 0.94;
float ka_wave = sin(ka_wave_rate*time);
float sine_ka = 0.35 * ka_wave;
float sine2_ka = 0.47 * sin(0.87*time);
float turn_t = turn + -0.0*time + sine_ka*sin(sine_kf*radius) + sine2_ka*sin(8.0 * angle);
bool turn_bit = mod(10.0*turn_t, 2.0) < 1.0;
float blend_k = pow((ka_wave + 1.0) * 0.5, 1.0);
vec3 c;
if(turn_bit) {
c = blend_k * c1a + (1.0 -blend_k) * c1b;
} else {
c = blend_k * c2a + (1.0 -blend_k) * c2b;
}
c *= 1.0 + 1.0*radius;
gl_FragColor = vec4(c, 1.0);
}
これでもう一度確認してみましょう、少なくとも筆者の環境では1時間程度放置しても特に変な劣化は見当たりません。
ちなみに、ここではかならず float time = u_time;
だけにしましょう。変に float time = u_time * 0.5
とか半分にしたり倍にしたりするのも放置すると時間経過とともに劣化します。一旦 time
にそのまま格納したらあとで time
でなにをしようか特に問題ないっぽいですが。
余談
Shader はすごいよ!というわけで、これから Shader もまじめに扱わないといけなくなってきたからとりあえず OpenGL ES の本を買って読んでみることにしましたけど、環境セットアップから Android が大変そうなのが伝わってきた…やっぱしばらくは iOS オンリーでいいや…