0
2

More than 1 year has passed since last update.

Pythonista3(WKWebView) とJavaScript の愛と友情のツープラトン 〜 コード間でのデータ受け渡し 〜。GLSL で遊ぶで!

Last updated at Posted at 2022-12-24

この記事は、Pythonista3 Advent Calendar 2022 の25日目の記事です。

一方的な偏った目線で、Pythonista3 を紹介していきます。

ほぼ毎日iPhone(Pythonista3)で、コーディングをしている者です。よろしくお願いします。

以下、私の2022年12月時点の環境です。

sysInfo.log
--- SYSTEM INFORMATION ---
* Pythonista 3.3 (330025), Default interpreter 3.6.1
* iOS 16.1.1, model iPhone12,1, resolution (portrait) 828.0 x 1792.0 @ 2.0

他の環境(iPad や端末の種類、iOS のバージョン違い)では、意図としない挙動(エラーになる)なる場合もあります。ご了承ください。

ちなみに、model iPhone12,1 は、iPhone11 です。

この記事でわかること

  • Pythonista3 を使ったJavaScript(WebGL) 開発
  • Pythonista3(WKWebView) と、JavaScript 間のデータ連携
  • (WebGL の楽しさと、GLSL の楽しさ)

気がつけば最終日

Advent Calendar も25日目。いよいよ最終日です。そして、つまりはクリスマスですね。

Python が実行できるiOS, iPadOS のアプリケーションである、Pythonista3。

そのPythonista3 紹介記事のアドカレ最終日のテーマが、JavaScript のWebGL(そしてGLSL)。

伏線でも、運命でも、何かに仕組まれた。なんてことは全くなく、ただただ私が紹介したいだけなのです😇

Pythonista3 の意外な使い方、特殊な活用方法をご紹介したいと思います。

私からのクリスマスプレゼントです。これでもくらえ🎁

今回の流れ

基本的に、wkwebview.py の活用をメインに実装、紹介をしていきます。

WebGL の部分は、コードの紹介のみに留めます。ほぼ知っている前提で説明をしてしまいます(すみません)。

詳しく知りたい方は、随時出てくるリンク先を参照ください。

WebGL: ウェブの 2D および 3D グラフィック - Web API | MDN

Pythonista3 -> WKWebView -> HTML | JavaScript -> WebGL; JavaScript -> WKWebView -> Pythonista3

実行すると、Pythonista3 のView でWebGL 実装のGLSL のシェーダーが描画されています。

textarea で、シェーダーコードを変更すると、シェーダー描画もコードの内容で反映されます。

View を閉じると、閉じる直前にtextarea へ記載されている情報(コードの文字列)をPythonista3 側で受け取ります。

今回は、Pythonista3 で受けたコードを、ローカルに設置しているシェーダーコードへ上書きをしています。

今回の実装では、

  • Pythonista3 ローカルでシェーダーコードをエディタ編集
  • 編集内容をView で描画確認
    • View 上でコードを編集
    • View クローズ時、ローカルファイルへ反映
  • Pythonista3 ローカルで、View 編集時のコードを継続編集

と、View 上と、Pythonista3 のエディタとを行き来しながら、シェーダーを書くことが可能になります。

事前のディレクトリ下準備

実装する前に下準備をおこないます。前回紹介記事の構成と基本的な変更はありません:

.
├── main.py                       <- Pythonista3 でView を呼び出す部分
├── docs                          <- Web アプリの部分
│   ├── css
│   │   ├── style.css
│   │   └── styleChecker.css
│   ├── index.html
│   ├── js
│   │   └── glCanvas.js
│   └── shaders
│       ├── fragment
│       │   └── fragmentMain.js
│       └── vertex
│           └── vertexMain.js
└── wkwebview.py                  <- pythonista-webview リポジトリのコードをそのまま移植

./docs/

html やjs のファイルが格納されている、Web アプリ側のファイル群です。

基本的に、通常通りWeb フロント側の実装で問題ありません。

Pythonista3(WKWebView) 側を意識せずに、実装できるように心がけています。

./docs/index.html

index.html
<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no">
  <link rel="stylesheet" href="./css/style.css">
  <script type="module">
    import eruda from 'https://cdn.skypack.dev/eruda';
    eruda.init();
  </script>
  <script type="module" src="./js/glCanvas.js" ></script>
  <title>WebGL でGLSL</title>
</head>
<body></body>
</html>

ただただ、私の「癖」でしかないのですが、事前に<body></body> タグ内には、何も入れずJavaScript 側で操作をします。

PC の潤沢なエディタたちがあれば、このような書き方は(多分)しませんが、Pythonista3 であるとコード間の行き来が面倒になってしまい、基本的に編集をするJavaScript ファイルで、こねこねしてしまっております。

./docs/css/style.css

style.css
@charset "utf-8";
/* @import url('./styleChecker.css'); */

html {
  height: 100%;
}
body {
  max-width: 720px;
  height: 100vh;
  height: 100dvh;
  margin: auto;

  background-color: darkslategray;
  color: #353535;
}

#wrap {
  height: 100%;
  display: flex;
  margin: 0 1rem;
  flex-direction: column;
  /*justify-content: center;*/
  align-items: center;
}

#myCanvas {
  margin: 1rem;
}

css もhtml 同様に、あまり書きたくないのですが、JavaScript となると煩雑化、処理の無駄感があるので、最低限は書いています。

JavaScript 側で書くとなると、結局のところ文字列操作になってしまい、いろいろと意味が無くなってしまうので。。。

コメントアウトしている、@import url('./styleChecker.css'); は、レイアウト構造や階層をうっすら色付けをしてくれるので、レイアウト確認に役立ちます。

気軽にdevtools を使えない苦肉の策のhack ですね。

CSSで実装したレイアウトの構造や階層を簡単に確認できる、私のお気に入りのCSSハック -My favorite CSS hack | コリス

My favorite CSS hack - DEV Community 👩‍💻👨‍💻

./docs/shaders

頂点シェーダーと、フラグメントシェーダーを分けて格納しています。

拡張子が.js となっているのは、Pythonista3 のエディタで編集をしたいためです。

.py よりも、シンタックスハイライト的には.js の方が近いです。

また、シェーダーコードの拡張子は.vert, .frag 等がよく使われています。

Pythonista3 でそのような拡張子を開こうとすると、無駄にワンアクション必要なのと、シンタックスハイライトなしの編集となるので、.js の方がいいな。というレベルの理由です。

fragmentMain.js
#version 300 es
precision highp float;

out vec4 fragColor;

uniform float time;
uniform vec2 mouse;
uniform vec2 resolution;

void main() {
  vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
  vec3 outColor = vec3(p, abs(sin(time)));

  fragColor = vec4(outColor, 1.0);
}

vertexMain.js
#version 300 es
in  vec3 position;
void main(){
    gl_Position = vec4(position, 1.0);
}

注意点としては、#version 300 es を必ず1行目に記載してください。1行目以外だと読み込みません。

脱線しますが、よくあるエラー例として、js 上でテンプレートリテラルを使う場面です:

.js
// 一行目が空行として解釈される
const NG_vertexCode = `
#version 300 es
in  vec3 position;
void main(){
    gl_Position = vec4(position, 1.0);
}
`

// バッククオート直後に記載することで、一行目からの文字列となる
const OK_vertexCode = `#version 300 es
in  vec3 position;
void main(){
    gl_Position = vec4(position, 1.0);
}

./docs/js/glCanvas.js

いよいよWebGL コアの部分です。

glCanvas.js
// sample_068
//
// GLSL だけでレンダリングする
// https://wgld.org/d/glsl/g001.html

// を、参照。一部改変

// global
let gl;
const uniLocation = new Array();

let cnvs, cnvsWidth, cnvsHeight;
let mouseX = 0.5;
let mouseY = 0.5;

// render ループ関係
let time = 0.0;
let prevTimestamp = 0;
const FPS = 120;
const frameTime = 1 / FPS;

// シェーダーコード関係
let prevFragmentCode = '';

const vs = './shaders/vertex/vertexMain.js';
const fs = './shaders/fragment/fragmentMain.js';

const vertexPrimitive = await fetchShader(vs);
const fragmentPrimitive = await fetchShader(fs);

async function fetchShader(path) {
  const res = await fetch(path);
  const shaderText = await res.text();
  return shaderText;
}

const setupDOM = () => {
  const wrapDiv = document.createElement('div');
  wrapDiv.id = 'wrap';
  const canvas = document.createElement('canvas');
  canvas.id = 'myCanvas';
  const textArea = document.createElement('textarea');
  textArea.id = 'textArea';
  textArea.value = fragmentPrimitive;
  textArea.style.width = '100%';
  textArea.style.height = '100%';
  document.body.appendChild(wrapDiv);
  wrapDiv.appendChild(canvas);
  wrapDiv.appendChild(textArea);

  textArea.addEventListener('input', (event) => {
    const textareaValue = event.target.value;
    const fragCode = textareaValue.replace(/\r?\n/g, '');
    if (prevFragmentCode != fragCode) {
      setupGL(vertexPrimitive, textareaValue);
      prevFragmentCode = fragCode;
    }
  });
};

setupDOM();
window.addEventListener('load', setupGL(vertexPrimitive, fragmentPrimitive));

function setupGL(vertexSource, fragmentSource) {
  // todo: js で生成しているのであれば、編集より取得でもいいかも
  // 画面サイズよりcanvas サイズを設定
  cnvsWidth = document.querySelector('#wrap').clientWidth;
  cnvsHeight = cnvsWidth * 0.64; // いい感じのサイズ調整
  // canvas エレメントを取得
  cnvs = document.querySelector('#myCanvas');
  cnvs.width = cnvsWidth;
  cnvs.height = cnvsHeight;

  // イベントリスナー登録
  // todo: touch イベントへ
  cnvs.addEventListener('mousemove', mouseMove, true);

  gl = cnvs.getContext('webgl2');
  // プログラムオブジェクトの生成とリンク
  const prg = create_program(
    // 頂点シェーダとフラグメントシェーダの生成
    create_shader('vs', vertexSource),
    create_shader('fs', fragmentSource)
  );
  uniLocation[0] = gl.getUniformLocation(prg, 'time');
  uniLocation[1] = gl.getUniformLocation(prg, 'mouse');
  uniLocation[2] = gl.getUniformLocation(prg, 'resolution');

  // 頂点データ回りの初期化
  const position = [
    -1.0, 1.0, 0.0, 1.0, 1.0, 0.0, -1.0, -1.0, 0.0, 1.0, -1.0, 0.0,
  ];
  const index = [0, 2, 1, 1, 2, 3];

  const vPosition = create_vbo(position);
  const vIndex = create_ibo(index);
  const vAttLocation = gl.getAttribLocation(prg, 'position');

  gl.bindBuffer(gl.ARRAY_BUFFER, vPosition);
  gl.enableVertexAttribArray(vAttLocation);
  gl.vertexAttribPointer(vAttLocation, 3, gl.FLOAT, false, 0, 0);
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, vIndex);

  // その他の初期化
  gl.clearColor(0.0, 0.0, 0.0, 1.0);
  mouseX = 0.5;
  mouseY = 0.5;

  renderLoop(); // レンダリング関数呼出
}

function renderLoop(timestamp) {
  const elapsed = (timestamp - prevTimestamp) / 1000;
  if (elapsed <= frameTime) {
    requestAnimationFrame(renderLoop);
    return;
  }
  prevTimestamp = timestamp;
  time += frameTime;
  glRender(time);
  requestAnimationFrame(renderLoop); // 再帰
}

function glRender(time) {
  gl.clear(gl.COLOR_BUFFER_BIT); // カラーバッファをクリア
  // uniform 関連
  gl.uniform1f(uniLocation[0], time);
  gl.uniform2fv(uniLocation[1], [mouseX, mouseY]);
  gl.uniform2fv(uniLocation[2], [cnvsWidth, cnvsHeight]);
  gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0); // 描画
  gl.flush();
}

// mouse
function mouseMove(e) {
  mouseX = e.offsetX / cnvsWidth;
  mouseY = e.offsetY / cnvsHeight;
}

/* シェーダを生成・コンパイルする関数 */
function create_shader(type, text) {
  let shader;
  // scriptタグのtype属性をチェック
  switch (type) {
    case 'vs': // 頂点シェーダの場合
      shader = gl.createShader(gl.VERTEX_SHADER);
      break;
    case 'fs': // フラグメントシェーダの場合
      shader = gl.createShader(gl.FRAGMENT_SHADER);
      break;
    default:
      return;
  }

  gl.shaderSource(shader, text); // 生成されたシェーダにソースを割り当てる
  gl.compileShader(shader); // シェーダをコンパイルする
  // シェーダが正しくコンパイルされたかチェック
  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    return shader; // 成功していたらシェーダを返して終了
  } else {
    // 失敗していたらエラーログをアラートしコンソールに出力
    // alert(gl.getShaderInfoLog(shader));
    console.log(gl.getShaderInfoLog(shader));
  }
}

// プログラムオブジェクトを生成しシェーダをリンクする関数
function create_program(vs, fs) {
  const program = gl.createProgram(); // プログラムオブジェクトの生成

  // プログラムオブジェクトにシェーダを割り当てる
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program); // シェーダをリンク

  // シェーダのリンクが正しく行なわれたかチェック
  if (gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.useProgram(program); // 成功していたらプログラムオブジェクトを有効にする
    return program; // プログラムオブジェクトを返して終了
  } else {
    return null; // 失敗していたら NULL を返す
  }
}

// VBOを生成する関数
function create_vbo(data) {
  const vbo = gl.createBuffer(); // bufferObject の生成

  gl.bindBuffer(gl.ARRAY_BUFFER, vbo); // buffer をバインドする
  gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW); // buffer にデータをセット
  gl.bindBuffer(gl.ARRAY_BUFFER, null); // buffer のバインドを無効化

  return vbo; // 生成した VBO を返して終了
}

// IBOを生成する関数
function create_ibo(data) {
  const ibo = gl.createBuffer(); // bufferObjectの生成

  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); // buffer をバインド
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Int16Array(data), gl.STATIC_DRAW); // buffer にデータをセット
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null); // buffer のバインドを無効化

  return ibo; // 生成したIBOを返し終了
}

調子にのって、webgl2 でのGLSL にしてみました。まだまだ理解できていないので無意味な部分が多々あるかもしれません。

実装内容に関しては、wgld.org を進めていけば理解できます。

wgld.org

私は、各章を写経して勉強しています(Pythonista3 で)。

最先端のWebGL ですと、iPhone が対応していない可能性もありますが、基本的な部分はiPhone で問題ありません。

実装サンプルページがあるので、ページに行き動いていれば実装可能です。

サンプルコードがvar で変数宣言されているので、const, let で書き換えつつ、WebGL 内の挙動を確認していけば、どんな手順でレンダリングが行われているのか流れが理解できてくると思います。

シェーダーコードの取得

wgld.org のサンプルでは、index.html 内にシェーダーコードを入力し読み込みをさせています。

私は、シェーダーコードとして個別に存在していた方が編集管理しやすいので、ローカルからシェーダーコードのファイルを取得させるようにしています。

.js
// シェーダーコード関係
let prevFragmentCode = '';

const vs = './shaders/vertex/vertexMain.js';
const vertexPrimitive = await fetchShader(vs);

const fs = './shaders/fragment/frag300.js';
const fragmentPrimitive = await fetchShader(fs);

async function fetchShader(path) {
  const res = await fetch(path);
  const shaderText = await res.text();
  return shaderText;
}

同期非同期・Promise, async/await、について完全に「雰囲気」でやっているので、変な実装かもしれませんが、「動くからヨシ!」の精神で実装させてもらっています。

setupGL 関数内で、シェーダーのコード情報を反映させることにしており、window.onload 時に、処理が走るようにしています。

引数として、対象のコードが格納されている変数を指定しています。指定せずに、グローバルな状態の変数で関数内で呼び出しても、取得ができませんのでお気をつけください。

.js
window.addEventListener('load', setupGL(vertexPrimitive, fragmentPrimitive));

Pythonista3 側の処理

かなり駆け足でWeb アプリ側の説明をしました。WebGL の部分は余裕で25日間分以上の記事になるレベルですので、今回はコピペしたら動いた。状態で問題ないです。

Pythonista3 のView 側の実装を見ていきましょう。wkwebview.py はモジュールとして設置しているので、直接書き加えたりはしません。main.py で実装を組んでいきます。

main.py
import sys
from pathlib import Path

import ui

sys.path.append(str(Path.cwd()))
from wkwebview import WKWebView

js_func = '''function getShaderCode() {
  const area = document.querySelector('#textArea');
  return `${area.value}`;
}
'''


class View(ui.View):
  def __init__(self, root, save_path, *args, **kwargs):
    ui.View.__init__(self, *args, **kwargs)
    self.save_path = save_path

    self.wv = WKWebView()
    self.wv.flex = 'WH'
    self.add_subview(self.wv)

    self.wv.load_url(str(root))
    self.wv.add_script(js_func)
    self.wv.clear_cache()
    self.set_reload_btn()

  def will_close(self):
    self.refresh_webview()

  def set_reload_btn(self):
    self.refresh_btn = self.create_btn('iob:ios7_refresh_outline_32')
    self.refresh_btn.action = (lambda sender: self.refresh_webview())
    self.right_button_items = [self.refresh_btn]

  def create_btn(self, icon):
    btn_icon = ui.Image.named(icon)
    return ui.ButtonItem(image=btn_icon)

  def refresh_webview(self):
    self.get_shader_code()
    self.wv.reload()

  def get_shader_code(self):
    js_code = 'getShaderCode()'
    self.wv.eval_js_async(js_code, lambda v: overwrite_code(v, self.save_path))


def overwrite_code(value, save_path):
  shader_path = Path(f'{save_path}')
  shader_path.write_text(value, encoding='utf-8')


if __name__ == '__main__':
  entry_point = Path('./docs/index.html')
  save_uri = Path('./docs/shaders/fragment/fragmentMain.js')

  view = View(entry_point, save_uri)
  view.present(style='fullscreen', orientations=['portrait'])

WebView として、wkwebview.py を使ったWKWebView の基本的な処理は変わりありません。

追加点として:

  • main.py 側でJavascript 内で実行させたい関数を定義
  • 終了時と更新時に、Web アプリ内の編集されたシェーダーコードを取得
  • ローカルのシェーダーコードファイルに上書き

Pythonista3 側で希望しているものは、Pythonista3 で準備させることで、./docs のフォルダが独り立ちして他環境で実行されても問題なく、不要な情報なく動くようにしています。

main.py 側でJavascript 内で実行させたい関数を定義

.py
# js 側で実行したい関数を定義
#   存在している(はず)のtextarea Node の値を文字列で返す
js_func = '''function getShaderCode() {
  const area = document.querySelector('#textArea');
  return `${area.value}`;
}
'''


class View(ui.View):
  def __init__(self, root, save_path, *args, **kwargs):
    ui.View.__init__(self, *args, **kwargs)
    self.save_path = save_path

    self.wv = WKWebView()
    self.wv.flex = 'WH'
    self.add_subview(self.wv)

    self.wv.load_url(str(root))
    # url を読み込んだあと、定義したjs の関数をWeb アプリ側に定義をしている
    self.wv.add_script(js_func)

.add_script することで、まだ呼び出しはせず、グローバル上にgetShaderCode 関数が存在するようにしています。

終了時と更新時に、Web アプリ内の編集されたシェーダーコードを取得

.py
def get_shader_code(self):
  js_code = 'getShaderCode()'
  self.wv.eval_js_async(js_code, lambda v: overwrite_code(v, self.save_path))

Pythonista3(Python 上) のget_shader_code が呼ばれた時に、仕込んでいたgetShaderCode がjs 側で走るようにしています。

.eval_js_async の第一引数で、js の世界に何かちょっかいをかける指示ができます。今回は仕込んだ関数を実行、第二引数でコールバック処理として、return された文字列の値を受けています。

ローカルのシェーダーコードファイルに上書き

.py
def overwrite_code(value, save_path):
  shader_path = Path(f'{save_path}')
  shader_path.write_text(value, encoding='utf-8')

Pathlib モジュールの、Path.write_text で、力技のごとく無理矢理ファイルに文字列を入れています。

直上のディレクトリまで存在をしていれば、該当ファイルが存在していない場合には新規作成をしてくれるので、上書き処理同様Pathlib の処理は力技でおこなっています。

次回は

(もう「次回は」はないですね。寂しいものです)

Pythonista3 からWKWebView を使い、Web アプリケーションが作れましたね。

そして、「Pythonista3 から実行している」ならでは、の機能もつけることができました。

(私の場合は)Pythonista3(Python)<-> Javascript 連携機能として、発想が乏しく今回の実装内容の紹介に甘んじてしまいました。

いい感じに連携をすれば、もっともっといい感じな何かしらができるのではないかなと感じています。

GLSL に関しては、scene モジュール紹介以来2回目ですね!

前回では、scene モジュール(SpriteKit) の仕様上、vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y); の正規化宣言が難しかったのですが、今回より気にせずにコードに書くことができます。

WebGL の環境構築をしてみると、実装背景も見えてくるので、何をコピペしてOKか?NGか?の判断が早くなりGLSL 生活がより一層豊かになります。

pome-ta/shaderCodeMirror6WebGL

今回は、textarea でコード入力部分は、お茶を濁したかたちになっていますが、CodeMirror とtwigl をベースとした、iPhone からでもまぁ無理なく入力できる環境を作っています。

CodeMirror

doxas/twigl: twigl.app is an online editor for One tweet shader, with gif generator and sound shader, and broadcast live coding.

実装は基本的にPythonista3 でおこなっており、ほんの一部のjs 機能の部分を他のiOS アプリで作っています。

./docs を綺麗に分割することで、しっかりとサーバー上でも動いてくれるのはいいところですね。

ここまで、読んでいただきありがとうございました。

せんでん

Discord

Pythonista3 の日本語コミュニティーがあります。みなさん優しくて、わからないところも親身に教えてくれるのでこの機会に覗いてみてください。

書籍

iPhone/iPad でプログラミングする最強の本。

その他

  • サンプルコード

Pythonista3 Advent Calendar 2022 でのコードをまとめているリポジトリがあります。

コードのエラーや変なところや改善点など。ご指摘やPR お待ちしておりますー

  • Twitter

なんしかガチャガチャしていますが、お気兼ねなくお声がけくださいませー

  • GitHub

基本的にGitHub にコードをあげているので、何にハマって何を実装しているのか観測できると思います。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2