※ この記事は 2022年8月 に作成したものを一部改稿したものです。
この記事は【後編】となっていますので、【前編】をお読みになっていない方はまずそちらからお読みください。
ライティングの追加
3DCG をリアルに表現するための手段として、ライティングによる陰影付けがよく利用されます。
ここでは、前編で作成した 3DCG にライティングを追加してみたいと思います。
ライティングを追加するための変更点は、頂点シェーダに送信する頂点属性を追加することと、フラグメントシェーダに送信する uniform
変数を追加することです。
変更後の頂点シェーダのソースコードは以下のようになります。
#version 300 es
layout(location = 0) in vec3 position;
layout(location = 1) in vec4 color;
layout(location = 2) in vec3 normal;
uniform mat4 mvpMatrix;
out vec4 vertexColor;
out vec3 vertexNormal;
void main() {
vertexColor = color;
vertexNormal = normal;
gl_Position = mvpMatrix * vec4(position, 1.0);
}
normal
という変数が追加されており、vertexNormal
という変数でフラグメントシェーダにデータを渡しています。
送信するデータは以下のようにして作成しています。
let surface_normals = [
[ 0.0, 0.0, 1.0 ],
[ 0.0, 0.0, -1.0 ],
[ 0.0, 1.0, 0.0 ],
[ 0.0, -1.0, 0.0 ],
[ 1.0, 0.0, 0.0 ],
[-1.0, 0.0, 0.0 ],
];
let normals = surface_normals.iter()
.flat_map(|n| n.repeat(4))
.collect::<Vec<_>>();
normal
は法線を意味する英語で、法線 (法線ベクトル) とはある面に対して垂直なベクトルのことです。
立方体の各面における法線ベクトルの向きは一定なので、各面の頂点の数 (4つ) だけ繰り返して各頂点における法線を表す配列 (ベクタ) を作成しています。
変更後のフラグメントシェーダのソースコードは以下のようになります。
#version 300 es
precision highp float;
in vec4 vertexColor;
in vec3 vertexNormal;
uniform mat4 invMatrix;
uniform vec3 lightDirection;
uniform vec3 eyeDirection;
uniform vec3 ambientColor;
out vec4 fragmentColor;
void main() {
vec3 invLight = normalize(invMatrix * vec4(lightDirection, 1.0)).xyz;
vec3 invEye = normalize(invMatrix * vec4(eyeDirection, 1.0)).xyz;
vec3 halfVector = normalize(invLight + invEye);
float diffuse = clamp(dot(invLight, vertexNormal), 0.1, 1.0);
float specular = pow(clamp(dot(halfVector, vertexNormal), 0.0, 1.0), 25.0);
fragmentColor = vec4(vertexColor.rgb * diffuse + specular + ambientColor, vertexColor.a);
}
光を計算する処理とそのための uniform
変数が大きく増えていることが分かります。
ライティングにおける光には、大きく分けて拡散光・反射光・環境光の3種類があります。
今回の例では、モデル座標変換行列の逆行列と光源・視点の方向からライトベクトルとハーフベクトルと呼ばれるベクトルを作成し、法線ベクトルとの内積から拡散光と反射光を算出して出力色に反映しています。
環境光についてはアプリケーション側から受け取った値をそのまま出力色に色に加算しています。
uniform
変数の送信処理は以下のようになります。
fn get_uniform_location_map(gl: &GL, program: &WebGlProgram) -> HashMap<String, WebGlUniformLocation> {
let uniforms = [
"mvpMatrix",
"invMatrix",
"lightDirection",
"eyeDirection",
"ambientColor",
];
let mut map = HashMap::new();
uniforms.iter().for_each(|&u| {
map.insert(String::from(u), gl.get_uniform_location(&program, u).expect("Failed to get uniform location"));
});
map
}
fn send_uniforms(
gl: &GL,
location_map: &HashMap<String, WebGlUniformLocation>,
canvas: &HtmlCanvasElement,
frame_count: i32,
) {
let radians = (frame_count % 360) as f32 * consts::PI / 180.0;
let mut model_matrix = glm::rotate_x(&glm::Mat4::identity(), radians);
model_matrix = glm::rotate_y(&model_matrix, radians);
model_matrix = glm::rotate_z(&model_matrix, radians);
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;
gl.uniform_matrix4fv_with_f32_array_and_src_offset_and_src_length(
location_map.get("mvpMatrix"), false, &mat4_to_vec(mvp_matrix), 0, 0);
let inv_matrix = glm::inverse(&model_matrix);
gl.uniform_matrix4fv_with_f32_array_and_src_offset_and_src_length(
location_map.get("invMatrix"), false, &mat4_to_vec(inv_matrix), 0, 0);
let light_direction = glm::Vec3::new(1.0, 1.0, 1.0);
gl.uniform3fv_with_f32_array_and_src_offset_and_src_length(
location_map.get("lightDirection"), &vec3_to_vec(light_direction), 0, 0);
let eye_direction = eye - center;
gl.uniform3fv_with_f32_array_and_src_offset_and_src_length(
location_map.get("eyeDirection"), &vec3_to_vec(eye_direction), 0, 0);
let ambient_color = glm::Vec3::new(0.1, 0.1, 0.1);
gl.uniform3fv_with_f32_array_and_src_offset_and_src_length(
location_map.get("ambientColor"), &vec3_to_vec(ambient_color), 0, 0);
}
fn mat4_to_vec(mat4: glm::Mat4) -> Vec<f32> {
let arrays: [[f32; 4]; 4] = mat4.into();
arrays.iter().flat_map(|&a| a).collect::<Vec<_>>()
}
fn vec3_to_vec(vec3: glm::Vec3) -> Vec<f32> {
let arrays: [[f32; 3]; 1] = vec3.into();
arrays[0].to_vec()
}
uniform
変数が複数になったため、メモリ上の位置をマップに保持するように変更しています。
send_mvp_matrix
関数は send_uniform
関数に名称を変更し、複数の uniform
変数を送信しています。
また、躍動感を出すためにモデル座標変換行列の回転処理を変更しています。
以上の変更を行いライティングを追加したバージョンは、以下のページで確認できます。
テクスチャの適用
ここまでの例では、立方体の各面にはアプリケーション側から送信した色を描画していましたが、次は画像ファイルを読み込んで各面に貼り付けてみたいと思います。
今回は以下の画像を用意しました。
この可愛らしいキャラクターは Rust の マスコットキャラクターである蟹の Ferris くんです。
用意する画像は、1辺の長さが2の冪乗でなければならないという制約があります。(上の画像では 512×512)
変更点としては、画像ファイルをロードしてテクスチャを生成することと、頂点色の代わりにテクスチャ内の座標を頂点属性としてシェーダに送信することです。
テクスチャの生成処理は以下のようになります。
async fn create_texture(gl: &GL, path: &str) -> Result<WebGlTexture, JsValue> {
let texture = gl.create_texture().ok_or("Failed to create texture")?;
let img = ImageLoader::new(path).await?;
gl.bind_texture(GL::TEXTURE_2D, Some(&texture));
gl.tex_image_2d_with_u32_and_u32_and_html_image_element(
GL::TEXTURE_2D, 0, GL::RGBA as i32, GL::RGBA, GL::UNSIGNED_BYTE, &img)?;
gl.generate_mipmap(GL::TEXTURE_2D);
gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_MAG_FILTER, GL::LINEAR as i32);
gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_MIN_FILTER, GL::LINEAR as i32);
gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_S, GL::REPEAT as i32);
gl.tex_parameteri(GL::TEXTURE_2D, GL::TEXTURE_WRAP_T, GL::REPEAT as i32);
gl.bind_texture(GL::TEXTURE_2D, None);
Ok(texture)
}
テクスチャへの画像の適用は画像のロードが完了した後に行う必要があるため、async
キーワードを用いて非同期関数として宣言しています。
ImageLoader
は画像のロード完了を待機するために今回作成した構造体で、以下のような作りになっています。
struct ImageLoader {
image: Option<HtmlImageElement>,
}
impl ImageLoader {
fn new(path: &str) -> Self {
let img = HtmlImageElement::new().unwrap();
img.set_src(path);
Self {
image: Some(img),
}
}
}
impl Future for ImageLoader {
type Output = Result<HtmlImageElement, String>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match &self.image {
Some(image) => {
if image.complete() {
Poll::Ready(Ok(self.image.take().unwrap()))
} else {
let waker = cx.waker().clone();
let on_load_closure = Closure::once(move || {
waker.wake_by_ref();
});
image.set_onload(Some(on_load_closure.as_ref().unchecked_ref()));
on_load_closure.forget();
Poll::Pending
}
},
_ => Poll::Ready(Err(String::from("Failed to load image"))),
}
}
}
非同期関数内で .await
で待機するには、構造体に Future
トレイトを実装する必要があります。
トレイトは Java でいうところのインターフェースのようなもので、実装するには予め定義されたメソッドを実装する必要があります。
Future
トレイトは JavaScript でいうところの Promise
のようなもので、完了していない場合は poll
メソッドの中で Poll::Pending
を、完了した場合は Poll::Ready
を返却します。
今回の例では、ImageLoader::new
メソッドの中で HtmlImageElement
を作成して src
属性に画像のパスをセットし、onload
コールバックの中で Waker
を使用して再度 poll
関数が実行されるようにしています。
テクスチャを作成したら、WebGL 2 コンテキストにバインドします。
create_texure
関数が非同期関数であるため、それを呼び出す start
関数も非同期関数にします。
また、WebAssembly のエントリーポイントが非同期関数になるので、wasm-bindgen-futures
クレートを追加する必要があります。
#[wasm_bindgen(start)]
pub async fn start() -> Result<(), JsValue> {
console_error_panic_hook::set_once();
︙
let texture = create_texture(&gl, "texture.png").await?;
gl.active_texture(GL::TEXTURE0);
gl.bind_texture(GL::TEXTURE_2D, Some(&texture));
各シェーダのソースコードは以下のようになります。
- 頂点シェーダ
vertex.glsl
#version 300 es layout(location = 0) in vec3 position; layout(location = 1) in vec3 normal; layout(location = 2) in vec2 texCoord; uniform mat4 modelMatrix; uniform mat4 mvpMatrix; uniform mat4 normalMatrix; out vec3 vertexPosition; out vec3 vertexNormal; out vec2 vertexCoord; void main() { vertexPosition = (modelMatrix * vec4(position, 1.0)).xyz; vertexNormal = (normalMatrix * vec4(normal, 1.0)).xyz; vertexCoord = texCoord; gl_Position = mvpMatrix * vec4(position, 1.0); }
- フラグメントシェーダ
fragment.glsl
#version 300 es precision highp float; in vec3 vertexPosition; in vec3 vertexNormal; in vec2 vertexCoord; uniform vec3 lightPosition; uniform vec3 eyePosition; uniform vec3 ambientColor; uniform sampler2D sampler; out vec4 fragmentColor; void main() { vec3 light = normalize(lightPosition - vertexPosition); vec3 eye = normalize(vertexPosition - eyePosition); vec3 reflection = normalize(reflect(eye, vertexNormal)); float diffuse = max(dot(light, vertexNormal), 0.2); float specular = pow(max(dot(light, reflection), 0.0), 25.0); vec4 samplerColor = texture(sampler, vertexCoord); fragmentColor = vec4(samplerColor.rgb * diffuse + specular + ambientColor, samplerColor.a); }
頂点シェーダでアプリケーション側からテクスチャ座標を受け取り、フラグメントシェーダで texture
関数を用いてテクスチャ内の指定座標の色を取得しています。
フラグメントシェーダの sampler
変数には、1つ目の画像を表す整数値 0
を送信しています。
gl.uniform1i(location_map.get("sampler"), 0);
また、光の計算処理をハーフベクトルを使用しない方式に変更しています。
以上の変更を行いテクスチャを適用したバージョンは、以下のページで確認できます。
終わりに
前の記事に引き続き、Rust で WebGL を記述して WebAssembly にコンパイルし、ブラウザ上で実行して 3DCG を描画しました。
JavaScript 向けに作られた API を頑張って Rust で書いている感は多少ありますが、いざブラウザで Rust で記述したコードが動くと感動があります。
WebGL についても、GLSL を習得する必要があったりと学習コストは決して低くはありませんが、面白さと可能性を大いに感じました。
今後は立方体以外の立体の描画やマウス操作によるインタラクティブな処理なども実装してみたいと思っています。
Rust を本格的に書いたのは今回が初めてで、記事中でも Rust の文法についてはあまり触れることができなかったので、疑問点や改善点などありましたらコメントをいただけるとありがたいです。
参考文献
- 杉本雅広. 明解WebGL iOS/Androidも対応した3D CGプログラミングのWeb標準. リックテレコム, 2015, ISBN: 9784897979908.
https://www.books.or.jp/book-details/9784897979908 - WebGL: ウェブの 2D および 3D グラフィック - Web API | MDN
https://developer.mozilla.org/ja/docs/Web/API/WebGL_API - WebAssembly | MDN
https://developer.mozilla.org/ja/docs/WebAssembly - The Rust Programming Language 日本語版
https://doc.rust-jp.rs/book-ja/ - web-sys: requestAnimationFrame - The
wasm-bindgen
Guide
https://rustwasm.github.io/wasm-bindgen/examples/request-animation-frame.html - WebGL 開発支援サイト wgld.org
https://wgld.org/