【ASP.NET】WebGLを描画してみる

はじめに

自分は以前よりUnityでのシェーダープログラミング経験がありました。
シェーダープログラミングとは言い換えればGPUプログラミングですね。

その経験を活かせないか考えていたところ、WebではWebGLというAPIがありました。
こちらはWebで3Dモデルを動かしたりなど、GPU性能を活かそうと思ったら挙がる選択肢のようです。

今回はこのWebGLをASP.NETの環境で利用してみたので、その方法を書いていきます。

ポリゴン表示

ではまず最小構成として、ポリゴン1個を表示するようなWebページを作成します。

プロジェクト作成

プロジェクトの種類は「Blazor WebAssemblyスタンドアロンアプリ」を選択します。

今回は.NET9.0を使用していきます。

他はデフォルトのままで作成します。

プログラム

2つを新規作成、1つを修正します。

wwwrootフォルダにjsフォルダを作成します。
そのjsフォルダ内にwebgl-triangle.jsを作成します。

// C# から呼び出されるグローバル関数
window.initTriangle = (canvasId, vsSource, fsSource) => {
    const canvas = document.getElementById(canvasId);
    const gl = canvas.getContext("webgl");

    if (gl === null) {
        alert("Unable to initialize WebGL.");
        return;
    }

    // シェーダープログラムを作成
    const shaderProgram = initShaderProgram(gl, vsSource, fsSource);

    // 頂点データを定義
    const positions = [
        0.0, 0.5,
        -0.5, -0.5,
        0.5, -0.5,
    ];

    // 頂点バッファを作成し、データをGPUに送る
    const positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);

    // 画面をクリア
    gl.clearColor(0.0, 0.0, 0.0, 1.0); // 背景を黒に
    gl.clear(gl.COLOR_BUFFER_BIT);

    // シェーダープログラムを使用
    gl.useProgram(shaderProgram);

    // 頂点バッファをシェーダーの属性に接続
    const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
    gl.enableVertexAttribArray(vertexPosition);
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);

    // 三角形を描画
    gl.drawArrays(gl.TRIANGLES, 0, 3);
};

// --- WebGLヘルパー関数 ---
function initShaderProgram(gl, vsSource, fsSource) {
    const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
    const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);

    const shaderProgram = gl.createProgram();
    gl.attachShader(shaderProgram, vertexShader);
    gl.attachShader(shaderProgram, fragmentShader);
    gl.linkProgram(shaderProgram);

    if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
        alert(`Unable to initialize the shader program: ${gl.getProgramInfoLog(shaderProgram)}`);
        return null;
    }
    return shaderProgram;
}

function loadShader(gl, type, source) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);

    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        alert(`An error occurred compiling the shaders: ${gl.getShaderInfoLog(shader)}`);
        gl.deleteShader(shader);
        return null;
    }
    return shader;
}

Pagesフォルダの中にTriangle.razorを追加します。

@page "/triangle"
@inject IJSRuntime JSRuntime

<h3>Red Triangle with WebGL</h3>

<canvas id="webgl-canvas" width="640" height="480" style="border: 1px solid grey;"></canvas>

@code {
    // コンポーネントがブラウザに表示された後に一度だけ実行されるメソッド
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // GLSLシェーダーコードをC#の文字列として定義
            string vsSource = @"
                attribute vec4 aVertexPosition;
                void main() {
                  gl_Position = aVertexPosition;
                }";

            string fsSource = @"
                void main() {
                  gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 赤色
                }";

            // JavaScriptの 'initTriangle' 関数を呼び出し、必要なデータを渡す
            await JSRuntime.InvokeVoidAsync("initTriangle", "webgl-canvas", vsSource, fsSource);
        }
    }
}

最後に、wwwrootフォルダ内のindex.htmlを編集します。
<script src="_framework/blazor.webassembly.js"></script>の後ろに以下プログラムを追加。

<script src="js/webgl-triangle.js"></script>

実行

実行して/triangleのページに飛ぶと、赤色の三角形が表示されました。

プログラムを読んでみると、CanvasではWebGLが使えるということなんですかね。
追加のパッケージなどは必要ないが、JSで多少追加する必要があるんですね。

vsSourceが頂点シェーダーでfsSourceがフラグメントシェーダーですね。

string vsSource = @"
    attribute vec4 aVertexPosition;
    void main() {
      gl_Position = aVertexPosition;
    }";

string fsSource = @"
    void main() {
      gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // 赤色
    }";

GPGPU(ライフゲーム)

GPUプログラミングの強味は3Dモデルの表示だけではなく、計算タスクにも使用できる点です。
並列処理に特化しているためモデル化するのにハードルがありますが、上手くいけば劇的な処理時間の高速化が実現できます。

今回は代表的なライフゲームを実装してみます。

プログラム

同一プロジェクトに追加していきます、URLを分けることで別々に表示できます。

wwwroot/jsフォルダにlifegame.jsを作成します。

const webGLObjects = {};

// 1. 初期化
window.lifeGameInit = (canvasId, vsSource, simFsSource, displayFsSource, initialData) => {
    const canvas = document.getElementById(canvasId);
    const gl = canvas.getContext("webgl");
    if (!gl) return { success: false, error: "WebGL not supported" };

    const ext = gl.getExtension('OES_texture_float');
    if (!ext) return { success: false, error: "OES_texture_float not supported" };

    const simProgram = createProgram(gl, vsSource, simFsSource);
    const displayProgram = createProgram(gl, vsSource, displayFsSource);
    if (!simProgram || !displayProgram) return { success: false, error: "Shader program creation failed" };

    const positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);

    const width = canvas.width;
    const height = canvas.height;
    const initialPixels = new Float32Array(initialData);

    const state = {
        read: createFBO(gl, width, height, initialPixels),
        write: createFBO(gl, width, height, null)
    };

    webGLObjects[canvasId] = { gl, simProgram, displayProgram, positionBuffer, state };
    return { success: true };
};

// 2. 1フレーム分のシミュレーションと描画
window.lifeGameNextFrame = (canvasId) => {
    const obj = webGLObjects[canvasId];
    if (!obj) return;

    const { gl, simProgram, displayProgram, positionBuffer, state } = obj;
    const width = gl.canvas.width;
    const height = gl.canvas.height;

    // --- シミュレーション ---
    gl.bindFramebuffer(gl.FRAMEBUFFER, state.write.fbo);
    gl.useProgram(simProgram);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, state.read.texture);
    gl.uniform1i(gl.getUniformLocation(simProgram, "u_state"), 0);
    gl.uniform2f(gl.getUniformLocation(simProgram, "u_resolution"), width, height);
    const simPosLoc = gl.getAttribLocation(simProgram, "a_position");
    gl.enableVertexAttribArray(simPosLoc);
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.vertexAttribPointer(simPosLoc, 2, gl.FLOAT, false, 0, 0);
    gl.viewport(0, 0, width, height);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    // --- 表示 ---
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.useProgram(displayProgram);
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, state.write.texture);
    gl.uniform1i(gl.getUniformLocation(displayProgram, "u_state"), 0);
    gl.uniform2f(gl.getUniformLocation(displayProgram, "u_resolution"), width, height);
    const dispPosLoc = gl.getAttribLocation(displayProgram, "a_position");
    gl.enableVertexAttribArray(dispPosLoc);
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    gl.vertexAttribPointer(dispPosLoc, 2, gl.FLOAT, false, 0, 0);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

    let temp = state.read;
    state.read = state.write;
    state.write = temp;
};

// 3. リソース解放
window.lifeGameDispose = (canvasId) => {
    delete webGLObjects[canvasId];
};

// --- ヘルパー関数 ---
function createShader(gl, type, source) {
    const shader = gl.createShader(type);
    gl.shaderSource(shader, source);
    gl.compileShader(shader);
    if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        console.error("Shader compile error:", gl.getShaderInfoLog(shader));
        gl.deleteShader(shader);
        return null;
    }
    return shader;
}

function createProgram(gl, vsSource, fsSource) {
    const vs = createShader(gl, gl.VERTEX_SHADER, vsSource);
    const fs = createShader(gl, gl.FRAGMENT_SHADER, fsSource);
    const program = gl.createProgram();
    gl.attachShader(program, vs);
    gl.attachShader(program, fs);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error("Program link error:", gl.getProgramInfoLog(program));
        return null;
    }
    return program;
}

function createFBO(gl, width, height, data) {
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.FLOAT, data);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

    const fbo = gl.createFramebuffer();
    gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
    gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

    gl.bindTexture(gl.TEXTURE_2D, null);
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    return { fbo: fbo, texture: texture };
}

PagesフォルダにLife.razorを作成。

@page "/lifegame"
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable

<h3>Conway's Game of Life (GLSL)</h3>

<canvas id="life-canvas" width="512" height="512" style="border: 1px solid grey;"></canvas>

@code {
    private readonly string _canvasId = "life-canvas";
    private readonly int _width = 512;
    private readonly int _height = 512;

    private bool _hasRenderedInBrowser = false;
    private bool _animationRunning = false;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _hasRenderedInBrowser = true;
            await InitializeAndStartAnimation();
        }
    }

    private async Task InitializeAndStartAnimation()
    {
        string vertexShaderSource = @"
            attribute vec2 a_position;
            void main() { gl_Position = vec4(a_position, 0.0, 1.0); }";

        string simulationFsSource = @"
            precision highp float;
            uniform sampler2D u_state;
            uniform vec2 u_resolution;
            float readState(vec2 offset) {
                vec2 texCoord = (gl_FragCoord.xy + offset) / u_resolution;
                return texture2D(u_state, texCoord).r;
            }
            void main() {
                float sum = 0.0;
                sum += readState(vec2(-1.0, -1.0)); sum += readState(vec2(-1.0,  0.0)); sum += readState(vec2(-1.0,  1.0));
                sum += readState(vec2( 0.0, -1.0));                                      sum += readState(vec2( 0.0,  1.0));
                sum += readState(vec2( 1.0, -1.0)); sum += readState(vec2( 1.0,  0.0)); sum += readState(vec2( 1.0,  1.0));
                float currentState = readState(vec2(0.0, 0.0));
                float newState = currentState;
                if (currentState > 0.5) {
                    if (sum < 2.0 || sum > 3.0) newState = 0.0;
                } else {
                    if (sum == 3.0) newState = 1.0;
                }
                gl_FragColor = vec4(newState, newState, newState, 1.0);
            }";

        string displayFsSource = @"
            precision highp float;
            uniform sampler2D u_state;
            uniform vec2 u_resolution;
            void main() {
                float state = texture2D(u_state, gl_FragCoord.xy / u_resolution).r;
                gl_FragColor = vec4(0.0, state * 0.8, 0.0, 1.0);
            }";

        var random = new Random();
        var initialData = new float[_width * _height * 4];
        for (int i = 0; i < _width * _height; i++)
        {
            float state = random.NextDouble() > 0.5 ? 1.0f : 0.0f;
            int index = i * 4;
            initialData[index + 0] = state;
            initialData[index + 1] = state;
            initialData[index + 2] = state;
            initialData[index + 3] = 1.0f;
        }

        var result = await JSRuntime.InvokeAsync<InitResult>("lifeGameInit", _canvasId, vertexShaderSource, simulationFsSource, displayFsSource, initialData);

        if (result.Success)
        {
            _animationRunning = true;
            _ = AnimationLoop();
        }
        else
        {
            await JSRuntime.InvokeVoidAsync("console.error", "WebGL Initialization Failed: " + result.Error);
        }
    }

    private async Task AnimationLoop()
    {
        while (_animationRunning)
        {
            await JSRuntime.InvokeVoidAsync("lifeGameNextFrame", _canvasId);
            await Task.Delay(16); // 約60fps
        }
    }

    public async ValueTask DisposeAsync()
    {
        _animationRunning = false;
        if (_hasRenderedInBrowser)
        {
            await JSRuntime.InvokeVoidAsync("lifeGameDispose", _canvasId);
        }
    }

    private class InitResult
    {
        public bool Success { get; set; }
        public string? Error { get; set; }
    }
}

wwwrootフォルダ内のindex.htmlに以下を追記

<script src="js/lifegame.js"></script>

実行

実行して/lifegameのページに飛ぶと、ちゃんとライフゲームのアニメーションが表示されました。

今回は512×512マスですが、2048×2048マスのシミュレーションも試してみました。
その時もリアルタイムに遅延なく動いているように感じました。

計算量は多めに見積もって25億回/秒なのでCPUでは追いつきませんね。

なお、初期化時にボトルネックがあるようで起動に時間がかかります。
await JSRuntime.InvokeAsync<InitResult>(...で呼び出して、js内のconst canvas = ...が呼び出されるまで時間があるようなので、そこまでの内部処理がボトルネックとなっているのでしょうか?
なるべく大きい盤面で試してみたいので、また調べていきたいですね。

Three.js

WebGLの拡張ライブラリとしてThree.jsというものがあります。
こちらは3DCGを簡単に行うためのライブラリのようです。

3Dモデル・テクスチャの読み込み、マテリアル表現、カメラ操作、アニメーションなど幅広い機能がそろっているようです。

プログラム

wwwroot/jsフォルダ内にthree-interop.jsを作成。

const scenes = {};

// シーンを初期化する
window.initThreeScene = (canvasId) => {
    const canvas = document.getElementById(canvasId);
    if (!canvas) return;

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0xeeeeee);

    const camera = new THREE.PerspectiveCamera(75, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
    camera.position.z = 5;

    const renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
    renderer.setSize(canvas.clientWidth, canvas.clientHeight);

    const light = new THREE.AmbientLight(0x404040);
    scene.add(light);
    const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
    directionalLight.position.set(2, 2, 5);
    scene.add(directionalLight);

    scenes[canvasId] = { scene, camera, renderer, objects: [] };

    function animate() {
        requestAnimationFrame(animate);

        // シーン内のオブジェクトをアニメーションさせる(例)
        scenes[canvasId].objects.forEach(obj => {
            obj.rotation.x += 0.005;
            obj.rotation.y += 0.01;
        });

        renderer.render(scene, camera);
    }
    animate();
};

// C#から命令を受けて、シーンにキューブを追加する
window.addCubeToScene = (canvasId, x, y, z, color) => {
    const context = scenes[canvasId];
    if (!context) return;

    const geometry = new THREE.BoxGeometry();
    const material = new THREE.MeshStandardMaterial({ color: color });
    const cube = new THREE.Mesh(geometry, material);

    cube.position.set(x, y, z);
    context.scene.add(cube);
    context.objects.push(cube); // アニメーションさせるために配列に追加
};

// リソースを解放する
window.disposeThreeScene = (canvasId) => {
    const context = scenes[canvasId];
    if (context) {
        // ここでThree.jsのオブジェクトを丁寧に解放するのが望ましい
        // (ジオメトリ、マテリアル、テクスチャなど)
        // 簡単のため、ここではシーンの参照を削除するだけ
        delete scenes[canvasId];
    }
};

Pagesフォルダ内にThreeJsPage.razorを作成。

@page "/three"
@inject IJSRuntime JSRuntime
@implements IAsyncDisposable

<h3>Three.js with Blazor</h3>

<div class="my-3">
    <button class="btn btn-primary" @onclick="AddRandomCube">Add Random Cube</button>
</div>

<canvas id="three-canvas" width="800" height="600" style="border: 1px solid black;"></canvas>

@code {
    private readonly string _canvasId = "three-canvas";
    private bool _hasRenderedInBrowser = false;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            _hasRenderedInBrowser = true;
            // JSにシーンの初期化を命令
            await JSRuntime.InvokeVoidAsync("initThreeScene", _canvasId);
        }
    }

    private async Task AddRandomCube()
    {
        // OnAfterRenderAsyncが実行される前(=JSの準備ができる前)に
        // ボタンが押されてもエラーにならないようにする
        if (!_hasRenderedInBrowser) return;

        var random = new Random();
        float x = (float)(random.NextDouble() * 6 - 3);
        float y = (float)(random.NextDouble() * 6 - 3);
        float z = (float)(random.NextDouble() * 6 - 3);
        int color = random.Next(0x000000, 0xffffff);

        // JSにキューブの追加を命令
        await JSRuntime.InvokeVoidAsync("addCubeToScene", _canvasId, x, y, z, color);
    }

    public async ValueTask DisposeAsync()
    {
        if (_hasRenderedInBrowser)
        {
            // ページを離れるときにJS側のリソースを解放する
            await JSRuntime.InvokeVoidAsync("disposeThreeScene", _canvasId);
        }
    }
}

wwwrootフォルダ内のindex.htmlに追記。

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="js/three-interop.js"></script>

実行

実行して/threeページにアクセス、Add Random Cubeボタンを押すことでCanvas内にキューブが追加されていきます。

THREE系列の命令がthree.jsの恩恵ですね。
3D表現を簡単に行うためには必須のライブラリですね。

おわりに

Webページ(ASP.NETでも)でシェーダープログラミングが行えることがわかりました。

作例などを調べてみると、ダイナミックな描画やインタラクティブな効果をしているのが多くあります。
自分も何らかの作品を作って公開してみたいですね。

補足

WebGLを表示する方法として調べていたところ、Blazor.Extensions.CanvasというパッケージをNuGetでインストールするという情報がありました。

Blazor WebAssembly を触る – kondoumh のブログ

しかしこの情報は何年も前のものであり、この拡張機能は.NET6.0あたりまでしか対応していないようです。

投稿者プロフィール

MuramatsuYuki

関連記事

  1. AWS

    【AWS|ASP.NET】ASP.NET Coreで作成したWebアプ…

  2. 【VB.NET】環境構築

  3. AWS

    【AWS EC2】シミュレーションプログラムを動かしてみた

  4. 【ASP.NET|Three.js】VATを描画してみる

  5. 【ASP.NET】ASP.NET Core MVCでWebアプリを作成…

  6. Google認証の実装

最近の記事

  1. AWS
  2. AWS

制作実績一覧

  1. Checkeys