※ この記事は 2022年8月 に作成したものを一部改稿したものです。
WebGL (Web Graphics Library) はハードウェア向けの 3DCG ライブラリである OpenGL のWeb版で、ブラウザ上で GPU アクセラレータを用いて 2D や 3D の CG を描画できる技術です。
JavaScript の Web API として実装されているためプラグイン等は不要で、今日ではスマートフォン向けを含むほとんどのブラウザが対応しています。
WebGL を利用してWebページ上で 3DCG を描画するにあたり、普通に JavaScript で実装するだけではやや面白味に欠けるので、WebAssembly と組み合わせてみようと思います。
WebAssembly はモダンなWebブラウザで実行できる新しいタイプのコードで、バイナリ形式の低レベルなアセンブリ風言語です。
C や C++, Rust といった言語のコンパイル対象となり、これらの言語をブラウザ上で実行することができるほか、ネイティブに近い速度で動作するため、大幅なパフォーマンス向上を実現できます。
WebGL は 3DCG の描画のためにそれなりの計算量を要するため、WebAssembly のユースケースとして適しているといえます。
上述の通り、WebAssembly にコンパイルできる言語の1つに Rust があります。
Rust は Firefox ブラウザの開発元である Mozilla を中心に開発されたプログラミング言語で、「所有権」というモデルによりコンパイル時にメモリ安全性を担保するため GC (ガベージコレクション) が存在せず C と同等の実行時速度性能を持つことや、多くの言語で値が存在しないことを示すのに用いられる null
が存在しないことが特徴として挙げられます。
そこで本記事では、WebGL の処理を Rust で記述して WebAssembly にコンパイルし、ブラウザ上で実行してみたいと思います。
表示環境の確認
WebGL の実装方法について見ていく前に、利用中のブラウザが WebGL 2.0 の描画に対応しているか確認します。
以下の URL にアクセスしてみてください。
回転するカラフルな立方体が表示されたでしょうか。
表示されない場合、利用中のブラウザが WebGL 2.0 に対応していない可能性が高いです。
表示されても描画がカクつく場合や開発者ツールのコンソールに以下のようなエラーメッセージが表示されている場合、ブラウザのハードウェアアクセラレーションが無効になっている可能性があります。
ハードウェアアクセラレーションとは、映像などの処理を CPU の代わりに GPU に任せることでブラウザの動作を軽くする機能です。
Google Chrome の場合、[設定] > [システム] > [ハードウェア アクセラレーションが使用可能な場合は使用する] を ON にすることでハードウェアアクセラレーションを有効化できます。
(ブラウザの再起動が必要です。)
Webページの作成
まずは、ブラウザで表示するWebページを作成します。
今回は、Vite + Vanilla JS + TypeScript でテンプレートを作成します。
Vite は Vue.js の作者である Evan You 氏が開発したフロントエンド開発用の CLI ツールです。
以下のコマンドでテンプレートを作成できます。
$ npm create vite@latest <your-app-name> -- --template vanilla-ts
WebGL の描画は HTML5 の canvas
要素上で行われるので、index.html に <canvas>
を追加します。
変更後の index.html は以下のようになります。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<title>wasm-WebGL</title>
</head>
<body>
<canvas id="canvas"></canvas>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<body>
で読み込んでいる main.ts の内容については後述します。
WebGL の実装
ここからは、Rust で WebGL の処理を実装していきます。
Rust がインストールされていない場合はインストールし、以下のコマンドで Rust のプロジェクトを作成します。
$ cargo new --lib wasm
全体のフォルダ構成は以下のようになります。
$ tree
.
├── index.html
├── package.json
├── public
│ └── vite.svg
├── src
│ ├── main.ts
│ └── vite-env.d.ts
├── tsconfig.json
└── wasm
├── Cargo.toml
└── src
└── lib.rs
続いて、wasm/Cargo.toml ファイルに Rust のプログラムで利用する依存パッケージを追記していきます。
[package]
name = "webgl"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
default = ["console_error_panic_hook"]
[dependencies]
wasm-bindgen = "0.2.82"
js-sys = "0.3.59"
nalgebra-glm = "0.17.0"
console_error_panic_hook = { version = "0.1.6", optional = true }
[dependencies.web-sys]
version = "0.3.59"
features = [
"Window",
"Document",
"Element",
"HtmlCanvasElement",
"WebGl2RenderingContext",
"WebGlShader",
"WebGlProgram",
"WebGlBuffer",
"WebGlVertexArrayObject",
"WebGlUniformLocation",
]
wasm-bindgen
は WebAssembly と JavaScript との間の橋渡しを容易にしてくれるクレート (パッケージのようなもの) で、web-sys
, js-sys
は WebAssembly から Web API や JavaScript API を利用するためのクレートです。
続いて、wasm/src/lib.rs ファイルに Rust のコードを記述していきます。
最終的な内容は以下のようになります。
順を追って内容を見ていきます。
まず、以下の部分でパスのインポート等を行っています。
これにより、nalgebra_glm
クレートを glm
, std::cell::RefCell
構造体を RefCell
のように短い名前で利用できるようになります。
また、web_sys::WebGl2RenderingContext
構造体は頻繁に使用するので GL
という別名を付けています。
extern crate nalgebra_glm as glm;
use std::{
cell::RefCell,
f32::consts,
rc::Rc,
};
use wasm_bindgen::{
prelude::*,
JsCast,
};
use web_sys::{
WebGl2RenderingContext as GL,
*,
};
ここからは、start
関数を起点に処理を見ていきます。
関数宣言の前にある #[wasm_bindgen(start)]
の注釈で、WebAssembly のモジュールがロードされた際にこの関数をエントリーポイントとして実行するよう指定しています。
最初に記述されている以下の行では、Rust のコード内で panic
(回復不能なエラー) が発生した場合に、Webページのコンソールにエラー内容を表示するようフックを設定しています。
console_error_panic_hook::set_once();
次に、以下の部分で canvas
要素を取得し、サイズの設定と WebGL 2 コンテキストの取得を行っています。
let document = web_sys::window().unwrap()
.document().unwrap();
let canvas = document.get_element_by_id("canvas")
.ok_or("canvas not found")?
.dyn_into::<HtmlCanvasElement>()?;
canvas.set_width(768);
canvas.set_height(768);
let gl = canvas.get_context("webgl2")?
.ok_or("Failed to get WebGl2RenderingContext")?
.dyn_into::<GL>()?;
次に、create_program
関数を実行して WebGL のプログラムを作成し、WebGL 2 コンテキストに設定しています。
create_program
関数では create_shader
関数でコンパイルしたシェーダをプログラムにリンクして返却しています。
シェーダについては後述しますが、include_str!
マクロを使用してコンパイル時にシェーダのソースを別ファイルから読み込んでいます。
fn create_program(gl: &GL) -> Result<WebGlProgram, String> {
let vertex_shader = create_shader(&gl, GL::VERTEX_SHADER, include_str!("shader/vertex.glsl"))?;
let fragment_shader = create_shader(&gl, GL::FRAGMENT_SHADER, include_str!("shader/fragment.glsl"))?;
let program = gl.create_program().ok_or("Failed to create program object")?;
gl.attach_shader(&program, &vertex_shader);
gl.attach_shader(&program, &fragment_shader);
gl.link_program(&program);
if gl.get_program_parameter(&program, GL::LINK_STATUS).as_bool().unwrap_or(false) {
Ok(program)
} else {
let log = gl.get_program_info_log(&program).unwrap_or(String::from("Failed to link program"));
gl.delete_program(Some(&program));
Err(log)
}
}
fn create_shader(gl: &GL, shader_type: u32, source: &str) -> Result<WebGlShader, String> {
let shader = gl.create_shader(shader_type).ok_or("Failed to create shader object")?;
gl.shader_source(&shader, source);
gl.compile_shader(&shader);
if gl.get_shader_parameter(&shader, GL::COMPILE_STATUS).as_bool().unwrap_or(false) {
Ok(shader)
} else {
let log = gl.get_shader_info_log(&shader).unwrap_or(String::from("Failed to compile shader"));
gl.delete_shader(Some(&shader));
Err(log)
}
}
WebGL では、シェーダと呼ばれる GPU 上で実行されるプログラムを利用して描画を行います。
基本的な構成では、各頂点に対し実行され座標の変換を行う頂点シェーダと、各ピクセルに対し実行され出力する色を決定するフラグメントシェーダをセットで利用します。
シェーダは GLSL (OpenGL Shading Language) という C に似た文法の言語で記述します。
wasm/src/shader ディレクトリを作成し、シェーダのソースファイルを配置します。
各シェーダのソースコードは以下のようになります。
- 頂点シェーダ
vertex.glsl
#version 300 es layout(location = 0) in vec3 position; layout(location = 1) in vec4 color; uniform mat4 mvpMatrix; out vec4 vertexColor; void main() { vertexColor = color; gl_Position = mvpMatrix * vec4(position, 1.0); }
- フラグメントシェーダ
fragment.glsl
#version 300 es precision highp float; in vec4 vertexColor; out vec4 fragmentColor; void main() { fragmentColor = vertexColor; }
各コードの先頭に記述している #version 300 es
は、ソースが GLSL ES 3.0 で記述されていることを示しています。
WebGL 1.0 では GLSL ES 1.0 が利用されていましたが、WebGL 2.0 では GLSL ES 3.0 を利用することができます。
頂点シェーダでは、in
修飾子で宣言している position
, color
変数でアプリケーション側から頂点に関するデータを受け取り、main
関数内で組み込み変数 gl_Position
に値を代入することで頂点の座標を指定します。
uniform
修飾子で宣言している mvpMatrix
変数は、頂点とは直接関係がないデータをアプリケーション側から受け取るために使用されます。
フラグメントシェーダも同様にして出力する色を指定しますが、変数名は何でもよいです。
頂点シェーダとフラグメントシェーダはリンクしており、頂点シェーダからフラグメントシェーダにデータを受け渡すことができます。
上の例では、vertexColor
変数で頂点シェーダからフラグメントシェーダに色のデータを渡しています。
次に、以下の部分でシェーダに送信する頂点座標と頂点色、頂点の結び順を表す配列 (・ベクタ) を作成しています。
let vertices = [
// 前面
-0.5, -0.5, 0.5,
0.5, -0.5, 0.5,
0.5, 0.5, 0.5,
-0.5, 0.5, 0.5,
// 背面
-0.5, -0.5, -0.5,
-0.5, 0.5, -0.5,
0.5, 0.5, -0.5,
0.5, -0.5, -0.5,
// 上面
-0.5, 0.5, -0.5,
-0.5, 0.5, 0.5,
0.5, 0.5, 0.5,
0.5, 0.5, -0.5,
// 下面
-0.5, -0.5, -0.5,
0.5, -0.5, -0.5,
0.5, -0.5, 0.5,
-0.5, -0.5, 0.5,
// 右面
0.5, -0.5, -0.5,
0.5, 0.5, -0.5,
0.5, 0.5, 0.5,
0.5, -0.5, 0.5,
// 左面
-0.5, -0.5, -0.5,
-0.5, -0.5, 0.5,
-0.5, 0.5, 0.5,
-0.5, 0.5, -0.5,
];
let colors = [
1.0, 0.0, 0.0, 1.0,
0.0, 1.0, 0.0, 1.0,
0.0, 0.0, 1.0, 1.0,
1.0, 1.0, 0.0, 1.0,
].repeat(6);
let vertex_indices = [
0, 1, 2,
0, 2, 3,
];
let indices = [vertex_indices; 6].iter().enumerate()
.flat_map(|(i, v)| v.iter().map(move |u| u + 4 * i as u16))
.collect::<Vec<_>>();
頂点座標は、各頂点に対し X, Y, Z 座標を表す3つの値が対応します。
頂点色は、各頂点に対し R, G, B, A を表す4つの値が対応します。
頂点色は 0.0 ~ 1.0 の範囲の値で指定します。
また、WebGL では三角形の組み合わせで図形を描画するため、四角形を描画するためには三角形を2回描画する必要があります。
よって、立方体の各面の4つの頂点について、結び順を表す6つの値が対応します。
次に、create_vao
関数を実行して配列データを元に VAO (Vertex Array Object) を作成し、WebGL 2 コンテキストに設定しています。
fn create_vao(
gl: &GL,
vbo_data: &[&[f32]],
locations: &[u32],
ibo_data: &[u16],
vertex_count: i32,
) -> Result<WebGlVertexArrayObject, String> {
let vao = gl.create_vertex_array().ok_or("Failed to create vertex array object")?;
gl.bind_vertex_array(Some(&vao));
for i in 0..vbo_data.len() {
let vbo = gl.create_buffer().ok_or("Failed to create buffer")?;
gl.bind_buffer(GL::ARRAY_BUFFER, Some(&vbo));
unsafe {
let view = js_sys::Float32Array::view(&vbo_data[i]);
gl.buffer_data_with_array_buffer_view(GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW);
}
gl.enable_vertex_attrib_array(locations[i]);
let size = vbo_data[i].len() as i32 / vertex_count;
gl.vertex_attrib_pointer_with_i32(locations[i], size, GL::FLOAT, false, 0, 0);
}
let ibo = gl.create_buffer().ok_or("Failed to create buffer")?;
gl.bind_buffer(GL::ELEMENT_ARRAY_BUFFER, Some(&ibo));
unsafe {
let view = js_sys::Uint16Array::view(ibo_data);
gl.buffer_data_with_array_buffer_view(GL::ELEMENT_ARRAY_BUFFER, &view, GL::STATIC_DRAW);
}
gl.bind_vertex_array(None);
Ok(vao)
}
頂点座標と頂点色の配列 (スライス) は頂点バッファ (VBO) にバインドして JavaScript の Float32Array
として参照、
頂点の結び順の配列 (スライス) はインデックスバッファ (IBO) にバインドして Uint16Array
として参照し、
それぞれシェーダにデータを送信しています。
WebAssembly のメモリ内のスライスに対するビューを JavaScript の配列として作成するのはメモリ安全でない操作なので、unsafe
ブロックで囲う必要があります。
VAO は、頂点バッファやインデックスバッファを紐付けてまとめ、再利用しやすくするための仕組みです。
次に、以下の部分で深度テストとカリングの有効化を行っています。
gl.enable(GL::DEPTH_TEST);
gl.depth_func(GL::LEQUAL);
gl.enable(GL::CULL_FACE);
これらは、3DCG において深度 (奥行き) を計算し、視点から見える部分のみを描画して見えない部分の描画を省略する仕組みです。
次に、以下の部分でシェーダの uniform
変数のメモリ上の位置を取得しています。
let mvp_location = gl.get_uniform_location(&program, "mvpMatrix").ok_or("Failed to get uniform location")?;
頂点シェーダの in
変数は layout
修飾子で location
を指定していたため不要でしたが、uniform
変数は指定していないため、シェーダにデータを送信するために必要になります。
最後に、以下の部分でアニメーションの描画を行っています。
let mut frame_count = 0;
let closure = Rc::new(RefCell::new(None));
let clone = closure.clone();
*clone.borrow_mut() = Some(Closure::<dyn FnMut() -> Result<i32, JsValue>>::new(move || {
frame_count += 1;
send_mvp_matrix(&gl, &mvp_location, &canvas, frame_count);
draw(&gl, index_count);
request_animation_frame(closure.borrow().as_ref().unwrap())
}));
request_animation_frame(clone.borrow().as_ref().unwrap())?;
JavaScript でアニメーションを描画する際は Window.requestAnimationFrame()
メソッドのコールバック関数の中で Window.requestAnimationFrame()
メソッドにコールバック関数自身を渡して再帰的に処理を行いますが、Rust ではクロージャ内でクロージャ自身を参照することはできないため、Rc
(参照カウンタ) と RefCell
(内部可変性) を用いてクロージャへの可変な参照を持つスマートポインタを複数生成することで実現しています。
ブラウザのフレーム更新ごとに実行されるクロージャ (コールバック関数) 内では、頂点シェーダへの座標変換行列の送信と canvas
要素への再描画を行っています。
座標変換行列の送信処理は以下のようになっています。
fn send_mvp_matrix(
gl: &GL,
location: &WebGlUniformLocation,
canvas: &HtmlCanvasElement,
frame_count: i32,
) {
let radians = (frame_count % 360) as f32 * consts::PI / 180.0;
let axis = glm::Vec3::new(1.0, 1.0, 1.0);
let model_matrix = glm::rotate(&glm::Mat4::identity(), radians, &axis);
let eye = glm::Vec3::new(0.0, 0.0, 3.0);
let center = glm::Vec3::new(0.0, 0.0, 0.0);
let up = glm::Vec3::new(0.0, 1.0, 0.0);
let view_matrix = glm::look_at(&eye, ¢er, &up);
let aspect = canvas.width() as f32 / canvas.height() as f32;
let fovy = 45.0 * consts::PI / 180.0;
let near = 0.1;
let far = 10.0;
let projection_matrix = glm::perspective(aspect, fovy, near, far);
let mvp_matrix = projection_matrix ** view_matrix ** model_matrix;
let mvp_arrays: [[f32; 4]; 4] = mvp_matrix.into();
let mvp_matrices = mvp_arrays.iter().flat_map(|a| *a).collect::<Vec<_>>();
gl.uniform_matrix4fv_with_f32_array_and_src_offset_and_src_length(Some(location), false, &mvp_matrices, 0, 0);
}
座標変換行列にはモデル座標変換行列とビュー座標変換行列とプロジェクション座標変換行列があり、これらを全て掛け合わせた行列を頂点シェーダ内で頂点座標に掛けることで、頂点の描画位置が決定されます。
行列を掛け合わせる際には、プロジェクション → ビュー → モデル の順で掛け合わせる点に注意が必要です。
行列の計算には、nalgebra-glm というライブラリを利用しています。
今回の例では、1フレームごとに1°ずつ回転量が増加するようにモデル座標変換行列を生成しているため、立方体が回転するアニメーションが描画されます。
canvas
要素への再描画処理は、以下のように色と深度をクリアしてから描画を行っています。
fn draw(gl: &GL, index_count: i32) {
gl.clear_color(0.0, 0.0, 0.0, 1.0);
gl.clear_depth(1.0);
gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT);
gl.draw_elements_with_i32(GL::TRIANGLES, index_count, GL::UNSIGNED_SHORT, 0);
gl.flush();
}
WebAssembly へのコンパイルと JavaScript からの呼び出し
ここまでで Rust での WebGL の処理の実装が完了したので、WebAssembly にコンパイルしてみます。
WebAssembly へのコンパイルには、wasm-pack というツールを使用します。
インストールが完了したら、以下のコマンドを実行します。
$ wasm-pack build --target web ./wasm
成功すると、wasm/pkg ディレクトリに WebAssembly (.wasm) とグルーコード (.js) が生成されます。
Webページで WebAssembly をロードするには、以下のように src/main.ts でグルーコードからインポートした init
関数を実行します。
import init from '../wasm/pkg/webgl'
(async () => {
await init()
})()
以上で準備は完了です。
npm run dev
で Vite の開発サーバを起動してブラウザで表示すると、表示環境の確認時に見た回転する立方体が表示されるかと思います。
終わりに
本記事では【前編】として、ブラウザ上に 3DCG で立方体を描画するところまで実践しました。
【後編】では、この立方体に効果を追加したり各面に画像を貼り付けたりしてみたいと思います。