JavaScript
WebGL
XMLHttpRequest

XMLHttpRequestで外部ファイル読み込み:WebGLのGLSLコードを例に

言いたいこと

XMLHttpRequestで非同期的にデータをGETする関数をPromiseで包んだあとは、
Promise.then()もしくはasync function(){}で読みこんだデータを使った処理ができます。

これを使って、Three.jsなどで使うGLSLのshaderコードを外部ファイルに置くことができます。

導入

前回の記事を書いていて思ったのが、GLSLのコードをHTMLにベタ書きしていた場合、例えば距離関数を別の物体に変えるときには

  • 数百行あるHTMLソースコードから該当箇所を見つける
  • 変更前の距離関数を消す、もしくは退避場所に置く
  • 新しい距離関数を上書き

と結構面倒です。距離関数ごとにHTMLファイルを増殖させると、今度は別の部分の変更があった場合すべてのHTMLファイルにわたって変更していかないといけません。

そこでJavaScriptのように外部ファイルにすることができれば、以下のようなメリットを期待できます。

  • 上記のような変更に強い
  • HTMLファイルの軽量化
  • GLSLコードをHTMLファイルから探さなくても良くなる
  • GLSLコードをエディタでシンタックスハイライトできる

また、実際にやってから気づいたメリットして、

  • 変更をリアルタイムで反映しようと思えばできる

があります。

ところがGLSLはHTMLとjavascriptほど仲が良くないので、<script src="hoge.frag">というように読み込むことができません。
一般的なテキストファイルと同様にリクエストを別に出して取ってくる必要があります。

逆に、GLSLのコードは文字列としてjavascript内で取り扱うことができれば、何でもいい話でもあります。とにかくTHREEなどに渡せばいいだけなので。

それでは具体的な方法を見ていきます。

ファイル読み込み

やりたいこと

Fragmentファイルが複数あって、そのurlが
codes = [url1, url2, url3, ... ]
と配列で準備されていたら、その中身の文字列を取得して、この順で繋げて1つの文字列として扱いたい。

基本

XMLHttpRequestを使います。いかにもXMLファイルを読み込むような名前のクラスですが、XMLでもないただのテキストファイルも読み込めます。

urlからテキストファイルを読み込むGETリクエストは以下のjavascriptで発行できます。

request.open('GET', url, true);
request.responseType = "text";
request.onload = function () {
    if (request.readyState == 4 || request.status == 200) {
        console.log("Successfully '" + url + "' loaded.");
        console.log(request.responseText)
    } else { 
        console.log("Error while loading '" + url + "'.");
    }
};
request.onerror = error_callback;
request.send(null);

ここでこれは非同期なので、request.onloadのコールバックでresult = request.responseTextとしても、request.send(null)のあとでresultにデータが正しく入っている可能性はありません。
request.open('GET', url, false);と3つ目の引数をfalseにすると同期処理でできるらしいのですが、公式ドキュメントによると非推奨とのこと。

あと、request.responseType = "text";はなくてもいいのですが、XMLファイルとしてのパースがなされ、コンソールを見ているとエラーが出ます。このオプションで取ってくるファイルはただのテキストだと教えています。

キャッシュ対策

実は、このXMLHttpRequestはブラウザのキャッシュが効きます。
キャッシュが効いていると、コードを変更しても以前読み込んでブラウザキャッシュに残っていた方を使ってしまいます。今回はいろいろ書き換えて確認したいので、このキャッシュは邪魔です。
以下のようにすればキャッシュがきかなくなります。

request.setRequestHeader('Pragma', 'no-cache');
request.setRequestHeader('Cache-Control', 'no-cache');
request.setRequestHeader('If-Modified-Since', 'Thu, 01 Jun 1970 00:00:00');

3つ目のはIE対応だそうです。

Promise

       p = new Promise( function (resolve, reject){
            var request = new XMLHttpRequest();
            request.open('GET', url, true);
            request.responseType = "text";
            request.onload = function () {
                if (request.readyState == 4 || request.status == 200) {            
                    resolve(request.responseText);
                } else { 
                    reject(new Error(request.statusText));
                }
            };
            request.send(null);
        });

pを何らかの形で実行すれば、Promiseで囲った関数が実行されます。

例:

  • p.then()
  • await p

複数のPromise

今回はコードを順番どおりに並べたいという意図があります。
実行自体は非同期でも、結果の順番は保証されて欲しいところです。
そんな時はPromise.all()を使いましょう。

function loadResources(urls) {
    var promises = urls.map(function(url){
        return new Promise( function (resolve, reject){
           中略
        });
    });
    以下略

promisesには、1つのファイルを読み込むPromiseurlsと同じ順で格納されます。
これを

hoge = Promise.all(promises);

とすれば、hogePromise型の変数で、promisesの中のすべてを実行し、それぞれのresolveによる戻り値をこの順で格納した配列を扱えるようになります。

Promise.all(promises).then(function(v){
   // v:[url1の結果, url2の結果, ・・・]
});

それでもやっぱり非同期

Promise.all(promises).then(function(value){
   hogehoge;
});
fugafuga;

ですが、hogehogeの部分ではpromisesの処理がなされたデータであることが保証されています。しかしfugafugaの部分に処理が来た時は、Promise.all自体の処理がまだ終わっていない可能性があります。

JavaScript初心者の僕ははじめに以下のように書きましたが、意図した動作になりませんでした。

function hoge(){
    /* 中略、 promisesを生成 */
    var result = "";
    Promise.all(promises).then(function(value){
        result = value.join('\n');
    });
    return result;
}

どうやってもhogeは空文字列を返します。一方で

var result = "";
function hoge(){
    /* 中略、 promisesを生成 */
    Promise.all(promises).then(function(value){
        result = value.join('\n');
    });
}
hoge();

とすると、しばらくたった後にresultを確認すると期待した文字列が格納されていました。まるで「金は払うと約束したがその日時と場所は指定していない、その気になれば10年後20年後ということも可能」と言われた気分でした。

とにかく、あくまで非同期処理としてしか扱えませんでした。つまりPromise.then()またはasync function(){}で確実にデータを持ってくる方法はわかりませんでした。

XMLHttpRequestを同期処理するようにオプションを変えるのも考えましたが、公式で非推奨であること、実際こういう外部リソース読み込みは非同期にしたほうが良さそうな気がするので、Promiseを使いこなす方向で行きたいと思います。

結論

then()の中でTHREE.jsに読み込んだコードを渡して、レンダリングを開始してしまうことにしました。

THREE.jsで使うレンダリング用javascript objectの受け皿をグローバル変数で用意しておき、

<script>
var camera, scene, controls, renderer;
var geometry, material, mesh;
以下略、その他諸々の変数準備

fragment用コードを受け取りつつレンダラを初期化する関数を定義、

function material_init(fragment_code){
    scene = new THREE.Scene();
    geometry = new THREE.PlaneBufferGeometry( 2.0, 2.0 );
    material = new THREE.RawShaderMaterial( {
        uniforms: {
            resolution: { value: new THREE.Vector2( canvas.width, canvas.height ) },
            cameraWorldMatrix: { value: camera.matrixWorld },
            cameraProjectionMatrixInverse: { value: new THREE.Matrix4().getInverse( camera.projectionMatrix ) },
        },
        vertexShader: document.getElementById( 'vertex_shader' ).textContent,
        fragmentShader: fragment_code
    } );
    mesh = new THREE.Mesh( geometry, material );
    mesh.frustumCulled = false;
    scene.add( mesh );
}

あとは1フレーム描写する関数render()を定義しておけば、

    Promise.all(promises).then(function(values){
        result = values.join("\n");
        material_init(result);
        render();
    }, function(e){
        console.error(e);
    });

あるいはawaitを使って

async function loadResources(urls) {
    var promises = urls.map(function(url){
   // 中略
    });

    result = (await Promise.all(promises)).join("\n");
    material_init(result);
}

とできます。

JavaScriptの設計思想って

この節は調べてて思ったことを書いた感想文です。
今回は「ファイルを読み込んだ後にその内容でマテリアルを定義する」という一連のフローをやらせたかったのでした。JS初心者の自分はこれをJavaScriptの一番上に書こうとしたのですが、そのまま書くことができませんでした。先ほどの節のように、空文字を返すhoge()しか実装できません。
一方でブラウザの処理を考えると「ページを一定のところまで読み込んだ後、もしくはボタンを押した後、ファイルを読み込んで以下略」とできるので、イベント駆動的な関数を定義してその中でフローを書いて初めて実装できました。

そもそもJavaScriptのトップレベルでフローを書くべきでないのでは、と思いました。HTML上でのJavaScriptは外部jsファイルを読み込んだり、<script>タグが分散する可能性があったりと、フローを非常に追いにくくなることがあるかと思います。

こう考えると、JavaScriptで関数で適宜処理をまとめることの利点として「スコープを限定する」の他に、「フローを追う長さを小さくできる」「トップレベルでは変数・関数定義とイベント登録だけでよい」といったこともあるように感じました。

ただ文末が});みたいになるのは少々つらいですね。

JS初心者なので適当なことを言っています。

できたもの

コード全体像

基本的には前回の記事と同様に、
https://qiita.com/gam0022/items/03699a07e4a4b5f2d41f
のサンプルの改造です。
さっきの節でJavaScriptの設計思想について偉そうにうんちくを書いておきながら、実際のコードはスパゲティです。

header.frag
precision mediump float;
uniform vec2 resolution;
uniform mat4 viewMatrix;
uniform vec3 cameraPosition;
uniform mat4 cameraWorldMatrix;
uniform mat4 cameraProjectionMatrixInverse;
const float EPS = 0.01;
const float OFFSET = EPS * 5.0;

const vec3 lightDir = vec3( 0.0, 1.0, 0.0 );

const int ITER = 80;
const float ITER_SHADOW = 80.0;
sphere.frag
float sceneDist( vec3 p ) {
    return length(p)-1.0;
}
vec3 sceneColor( vec3 p ) {
    return vec3( 0.0, 1.0, 0.0 );
}
vec3 getNormal( vec3 p ) {
    return p;
} 
torus.frag
const vec3 t = vec3(1.0, 0.2, 0.0); // radian, width, 0
float sceneDist( vec3 p ) {
    vec2 q = vec2(length(p.xz)-t.x,p.y);
    return length(q)-t.y;
}
vec3 sceneColor( vec3 p ) {
    return vec3( 0.0, 1.0, 0.0 );
}
vec3 getNormal( vec3 p ) {
    float r = t.x/length(p.xz);
    vec3 q = vec3(p.x*r, 0.0, p.z*r);
    return p - q;
}
line_segment.frag
const vec3 p1 = vec3(-1.3, -2.1, 5.8);
const vec3 p2 = vec3(2.1, 1.8, -2.9);
const float radii = 0.3;
float sceneDist(vec3 p){
    vec3 p12 = p2 - p1;
    float q = clamp(dot(p - p1, p12)/dot(p12, p12), 0.0, 1.0);
    return length(p - mix(p1, p2, q)) - radii;
}

vec3 sceneColor(vec3 p){
    return vec3(1.0, 0.0, 0.0);
}

vec3 getNormal(vec3 p){
    vec3 p12 = p2 - p1;
    float q = clamp(dot(p - p1, p12)/dot(p12, p12), 0.0, 1.0);
    return normalize(p - mix(p1, p2, q));
}
render.frag
float getShadow( vec3 ro, vec3 rd ) {
    float h = 0.0;
    float c = 0.0;
    float r = 1.0;
    float shadowCoef = 0.5;
    for ( float t = 0.0; t< 50.0; t++ ) {
        h = sceneDist( ro + rd * c );
        if ( h < EPS ) return shadowCoef;
        r = min( r, h * 16.0 / (c + EPS));
        c += h;
    }
    return 1.0 - shadowCoef + r * shadowCoef;
}
vec3 getRayColor( vec3 origin, vec3 ray, out vec3 pos, out vec3 normal, out bool hit ) {
    // marching loop
    float dist;
    float depth = 0.0;
    pos = origin;
    for ( int i = 0; i < 64; i++ ){
        dist = sceneDist( pos );
        depth += dist;
        pos = origin + depth * ray;
        if ( abs(dist) < EPS ) break;
    }
    // hit check and calc color
    vec3 color;
    if ( abs(dist) < EPS ) {
        normal = getNormal( pos );
        float diffuse = clamp( dot( lightDir, normal ), 0.1, 1.0 );
        float specular = pow( clamp( dot( reflect( lightDir, normal ), ray ), 0.0, 1.0 ), 10.0 );
        float shadow = getShadow( pos + normal * OFFSET, lightDir );
        color = ( sceneColor( pos ) + vec3( 0.8 ) * specular ) * max( 0.5, shadow );
        hit = true;
    } else {
        color = vec3( 0.4 );
    }
    return color - pow( clamp( 0.05 * depth, 0.0, 0.6 ), 2.0 ) * 0.1;
}
void main(void) {
    vec2 screenPos = ( gl_FragCoord.xy * 2.0 - resolution ) / min( resolution.x, resolution.y );
    vec3 ray = (cameraWorldMatrix * cameraProjectionMatrixInverse * vec4( screenPos.xy, 1.0, 1.0 )).xyz;
    ray = normalize( ray );
    vec3 cPos = cameraPosition;
    vec3 color = vec3( 0.0 );
    vec3 pos, normal;
    bool hit;
    float alpha = 1.0;
    for ( int i = 0; i < 3; i++ ) {
        color += alpha * getRayColor( cPos, ray, pos, normal, hit );
        alpha *= 0.3;
        ray = normalize( reflect( ray, normal ) );
        cPos = pos + normal * OFFSET;
        if ( !hit ) break;
    }
    gl_FragColor = vec4( color, 1.0 );
} 
style.css
body {
    background-color: black;
    margin: 0;
    padding: 0;
}
a { color: skyblue }
canvas {
    display: block;
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    margin: auto;
}
#info {
    color: white;
    font-size: 13px;
    position: absolute;
    bottom: 10px;
    width: 100%;
    text-align: center;
    z-index: 100;
}
#fragment-code {
    overflow: scroll;
    white-space: nowrap;
    font-family: 'Courier New', Courier, monospace;
}
index.html
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>three.js webgl - raymarching - reflect</title>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
        <link rel="stylesheet" href="style.css" type="text/css">
    </head>
    <body>
<script id="vertex_shader" type="x-shader/x-vertex">
    attribute vec3 position;
    void main(void) {
        gl_Position = vec4(position, 1.0);
    }
</script>

<script src="js/three.min.js"></script>
<script src="js/controls/FlyControls.js"></script>
<script src="js/libs/stats.min.js"></script>
<script src="js/libs/dat.gui.min.js"></script>

<script>
var camera, scene, controls, renderer;
var geometry, material, mesh;
var mouse = new THREE.Vector2( 0.5, 0.5 );
var canvas;
var view_center;
var stats;
var rotation_function = function(x, y){};
var clock = new THREE.Clock();
var config = {
    saveImage: function() {
        renderer.render( scene, camera );
        window.open( canvas.toDataURL() );
    },
    resolution: '512',
    cameraReset: camera_init,
    renderPattern: "sphere",
    load_code: function() {
        fragment_code = document.getElementById("fragment-code");
        loadResources([], fragment_code);
    },
};

var fragment_set ={
    "sphere": ['header.frag', 'sphere.frag', 'render.frag'],
    "torus": ['header.frag', 'torus.frag', 'render.frag'],
    "line segment": ['header.frag', 'line_segment.frag', 'render.frag'],
}

async function loadResources(urls, resource_store_textarea) {
    if(urls.length == 0){
        material_init(resource_store_textarea.value);
        render();
        return;
    }
    var promises = urls.map(function(url){
        return new Promise( function (resolve, reject){
            var request = new XMLHttpRequest();
            function error_callback() {
                console.log("Error while loading '" + url + "'.");
                reject(new Error(request.statusText));
            }
            request.open('GET', url, true);
            request.setRequestHeader('Pragma', 'no-cache');
            request.setRequestHeader('Cache-Control', 'no-cache');
            request.setRequestHeader('If-Modified-Since', 'Thu, 01 Jun 1970 00:00:00');
            request.responseType = "text";
            request.onload = function () {
                if (request.readyState == 4 || request.status == 200) {
                    console.log("Successfully '" + url + "' loaded.");
                    resolve(request.responseText);
                } else { 
                    error_callback();
                }
            };
            request.onerror = error_callback;
            request.send(null);
        });
    });

    result = (await Promise.all(promises)).join("\n");
    resource_store_textarea.value = result;
    compile_code(result);
}

function camera_init(){
    camera.position.set(0.0, 0.0, 5.0);
    camera.lookAt( new THREE.Vector3( 0.0, 0.0, 0.0 ));
    camera.up.set(0.0, 1.0, 0.0);
    view_center = new THREE.Vector3(0.0, 0.0, 0.0);
}

var rotation_horizon = function(angle){
    camera.position.sub(view_center);
    var r = camera.position.length();
    right_dir = camera.position.clone().cross(camera.up).normalize();
    camera.position.addScaledVector(right_dir, r*angle/2.0);
    camera.position.setLength(r);
    camera.position.add(view_center);
}
function rotation_vertical(angle){
    camera.position.sub(view_center);
    var r = camera.position.length();
    camera.position.addScaledVector(camera.up, r*angle/2.0);
    camera.position.setLength(r);
    camera.up = camera.position.clone().cross(camera.up).normalize();
    camera.up.cross(camera.position).normalize();
    camera.position.add(view_center);
}

function move_forward(dx){
    dr = camera.position.clone().sub(view_center).multiplyScalar(- dx);
    camera.position.add(dr);
    view_center.add(dr)
}

function camera_pan(angle){
    look_vec = view_center.clone().sub(camera.position);
    r = look_vec.length();
    right_dir = look_vec.clone().cross(camera.up);
    look_vec.addScaledVector(right_dir, angle/2.0).setLength(r);
    view_center.addVectors(camera.position, look_vec);
}

function camera_tilt(angle){
    look_vec = view_center.clone().sub(camera.position);
    r = look_vec.length();
    look_vec.addScaledVector(camera.up, angle*2.0).setLength(r);
    view_center.addVectors(camera.position, look_vec);
}

function material_init(fragment_code){
    scene = new THREE.Scene();
    geometry = new THREE.PlaneBufferGeometry( 2.0, 2.0 );
    material = new THREE.RawShaderMaterial( {
        uniforms: {
            resolution: { value: new THREE.Vector2( canvas.width, canvas.height ) },
            cameraWorldMatrix: { value: camera.matrixWorld },
            cameraProjectionMatrixInverse: { value: new THREE.Matrix4().getInverse( camera.projectionMatrix ) },
        },
        vertexShader: document.getElementById( 'vertex_shader' ).textContent,
        fragmentShader: fragment_code
    } );
    mesh = new THREE.Mesh( geometry, material );
    mesh.frustumCulled = false;
    scene.add( mesh );
}

function init() {
    renderer = new THREE.WebGLRenderer();
    renderer.setPixelRatio( window.devicePixelRatio );
    renderer.setSize( config.resolution, config.resolution );
    canvas = renderer.domElement;
    canvas.addEventListener( 'mousedown', onMouseDown, false );
    canvas.addEventListener( 'mouseup', onMouseUp, false );
    canvas.addEventListener( 'mouseout', function(e){onMouseUp(e);}, false );
    canvas.addEventListener( 'mousemove', onMouseMove, false );
    canvas.addEventListener( 'mousewheel', onMouseWheel, false);
    document.addEventListener( 'keydown', onKeyDown, false);
    window.addEventListener( 'resize', onWindowResize );
    document.body.appendChild( canvas );
    // Scene
    camera = new THREE.PerspectiveCamera( 60, canvas.width / canvas.height, 1, 2000 );
}

function init_controls(){
    // Controls
    controls = new THREE.FlyControls( camera, canvas );
    controls.autoForward = true;
    controls.dragToLook = false;
    controls.rollSpeed = Math.PI / 12;
    controls.movementSpeed = 0.5;
    // GUI
    var gui = new dat.GUI();
    gui.add( config, 'saveImage' ).name( 'Save Image' );
    gui.add( config, 'resolution', [ '256', '512', '800', 'full' ] ).name( 'Resolution' ).onChange( function( value ) {
        if ( value !== 'full' ) {
            canvas.width = value;
            canvas.height = value;
        }
        onWindowResize();
    } );
    gui.add( config, 'cameraReset').name("Reset Camera");
    gui.add( config, 'renderPattern', Object.keys(fragment_set)).name("Pattern").onChange( function( value ){
        fragment_files = fragment_set[value];
        fragment_code = document.getElementById("fragment-code");
        loadResources(fragment_files, fragment_code);
    } );
    gui.add( config, 'load_code').name("Load Code");
    stats = new Stats();
    document.body.appendChild( stats.domElement );
    fragment_code = document.createElement("textarea");
    fragment_code.id = "fragment-code";
    fragment_code.onChange = function(e){load_render_code();};
    document.body.appendChild(fragment_code);
}

function compile_code(){
    fragment_code = document.getElementById("fragment-code");
    material_init(fragment_code.value);
    render();
}

function render( timestamp ) {
    var delta = clock.getDelta();
    stats.begin();
    camera.lookAt(view_center);
    material.uniforms.resolution.value.set( canvas.width, canvas.height );
    material.uniforms.cameraProjectionMatrixInverse.value.getInverse( camera.projectionMatrix );
    renderer.render( scene, camera );
    stats.end();
    requestAnimationFrame( render );
}

function onMouseDown( event ) {
    rotation_function = function(x, y){
        rot_speed = 10.0;// controls.rollSpeed;
        rotation_horizon(x*rot_speed);
        rotation_vertical(y*rot_speed);
    }
}

function onMouseMove( event ) {
    px = mouse.x;
    py = mouse.y;
    mouse.x = event.clientX / canvas.width;
    mouse.y = event.clientY / canvas.height;
    rotation_function(mouse.x - px, mouse.y - py);
}

function onMouseUp( event ) {
    rotation_function = function(x, y){};
}

var delta = 0.05;
function onMouseWheel( event ){
    if(event.wheelDelta > 0){
        camera.position.multiplyScalar(1. + delta);
    }else{
        camera.position.multiplyScalar(1. - delta);
    }
}

function onKeyDown( event ){
    if(event.keyCode === 87){
        move_forward(0.01);
    }
    if(event.keyCode === 83){
        move_forward(-0.01);
    }
    if(event.keyCode === 37){
        camera_pan(0.05);
    }
    if(event.keyCode === 39){
        camera_pan(-0.05);
    }
    if(event.keyCode === 38){
        camera_tilt(0.05);
    }
    if(event.keyCode === 40){
        camera_tilt(-0.05);
    }
}

function onWindowResize( e ) {
    if ( config.resolution === 'full' ) {
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;
    }
    renderer.setSize( canvas.width, canvas.height );
}

init();
init_controls();
camera_init();
loadResources(fragment_set["sphere"], fragment_code);
        </script>
    </body>
</html>

以上の7つのファイルとこちらのjsフォルダを用意したディレクトリで、適当なHTTPサーバーを立ち上げます。
HTMLファイルをローカルで開いてもいいのですが、Chromeなど一部のブラウザはローカルのファイルアクセスが簡単にはできません。もういっそHTTPサーバー越しにアクセスしたほうが楽です。

$ ls
header.frag  js                 render.frag  style.css
index.html   line_segment.frag  sphere.frag  torus.frag
$ python -m "http.server"

このあと http://localhost:8000 にアクセスします。
python持ってないとかRubyじゃないととか言う人は、自分の好きな簡易HTTPサーバーを立ち上げると良いと思います。

カメラ操作

  • マウスドラッグ:World座標系原点周りにカメラを回転させます。
  • 上下左右の矢印キー:カメラの視線をその方向に向けます。
  • w:カメラが向いている方向にカメラを前進させます。
  • s:カメラが向いているのとは逆の方向にカメラを後退させます。

なお、初期状態はWorld座標系原点を向いているカメラですが、キー操作でカメラを動かしてしまうと原点から外れたところを向きます。結果、ドラッグ時の挙動が不自然になります。

コードその場編集

左側にテキストエリアが出現しました。ここには現在レンダリングしているfragコードを表示しています。
これはテキストとして編集できます。書き換えてから右側のメニューから「Load Code」をクリックすると、編集した内容でコンパイル・レンダリングが行われます。
コンパイルエラーした時は、開発者モードでエラーメッセージを確認してください。

実演

hoge.gif

補足

今回はレイマーチング用から始めたプロジェクトなので、fragmentシェーダのみ外部ファイル化しました。Vertexを外部ファイル化する場合も同様の処理でいけると思います。

その他

そろそろJavaScript部分も自分なりに書きなおしてもいい気がしてきた。