上級 graphics

WebGLによる3Dグラフィックス

WebGLレンダリングパイプライン

WebGLはOpenGL ES 2.0にほぼ1:1でマッピングされる低レベルグラフィックスAPIです。Rust/Wasmからレンダリングパイプラインの各段階を制御できます:

┌────────────────────────────────────────────────────────────────┐
│                    WebGLレンダリングパイプライン                  │
│                                                                │
│  ┌──────────┐   ┌──────────────┐   ┌────────────────────────┐ │
│  │ 頂点     │──►│ 頂点         │──►│ プリミティブ組立         │ │
│  │ バッファ  │   │ シェーダ     │   │ (三角形/線分)          │ │
│  │(データ) │   │(頂点ごと)   │   │                        │ │
│  └──────────┘   └──────────────┘   └───────────┬────────────┘ │
│                                                 │              │
│                                                 ▼              │
│  ┌──────────┐   ┌──────────────┐   ┌────────────────────────┐ │
│  │ フレーム  │◄──│ フラグメント  │◄──│ ラスタライゼーション     │ │
│  │ バッファ  │   │ シェーダ     │   │(三角形→ピクセル)      │ │
│  │(画面)   │   │(ピクセルごと)│   │                        │ │
│  └──────────┘   └──────────────┘   └────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘

RustからのWebGLセットアップ

[dependencies.web-sys]
version = "0.3"
features = [
    "HtmlCanvasElement",
    "WebGlRenderingContext",
    "WebGlProgram",
    "WebGlShader",
    "WebGlBuffer",
    "WebGlUniformLocation",
]
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{WebGlRenderingContext as GL, WebGlProgram, WebGlShader};

#[wasm_bindgen]
pub fn init_webgl(canvas_id: &str) -> Result<(), JsValue> {
    let document = web_sys::window().unwrap().document().unwrap();
    let canvas = document.get_element_by_id(canvas_id).unwrap()
        .dyn_into::<web_sys::HtmlCanvasElement>()?;

    let gl: GL = canvas.get_context("webgl")?
        .unwrap().dyn_into()?;

    gl.viewport(0, 0, canvas.width() as i32, canvas.height() as i32);
    gl.clear_color(0.0, 0.0, 0.0, 1.0);
    gl.enable(GL::DEPTH_TEST);
    gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT);

    Ok(())
}

シェーダ

シェーダはGPU上で実行される小さなプログラムです。WebGLには2種類必要です:

頂点シェーダ

頂点ごとに1回実行されます。3D座標をスクリーン座標に変換します:

attribute vec3 aPosition;
attribute vec3 aNormal;

uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;

varying vec3 vNormal;
varying vec3 vFragPos;

void main() {
    vec4 worldPos = uModel * vec4(aPosition, 1.0);
    vFragPos = worldPos.xyz;
    vNormal = mat3(uModel) * aNormal;  // 法線の変換
    gl_Position = uProjection * uView * worldPos;
}

フラグメントシェーダ

ピクセルごとに1回実行されます。最終的な色を決定します:

precision mediump float;

varying vec3 vNormal;
varying vec3 vFragPos;

uniform vec3 uLightDir;
uniform vec3 uLightColor;
uniform vec3 uObjectColor;

void main() {
    // 環境光
    float ambient = 0.1;

    // 拡散光
    vec3 norm = normalize(vNormal);
    float diff = max(dot(norm, uLightDir), 0.0);

    vec3 result = (ambient + diff) * uLightColor * uObjectColor;
    gl_FragColor = vec4(result, 1.0);
}

Rustからのシェーダコンパイル

fn compile_shader(gl: &GL, shader_type: u32, source: &str) -> Result<WebGlShader, String> {
    let shader = gl.create_shader(shader_type)
        .ok_or("シェーダを作成できません")?;
    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 {
        Err(gl.get_shader_info_log(&shader).unwrap_or_default())
    }
}

fn link_program(gl: &GL, vert: &WebGlShader, frag: &WebGlShader) -> Result<WebGlProgram, String> {
    let program = gl.create_program().ok_or("プログラムを作成できません")?;
    gl.attach_shader(&program, vert);
    gl.attach_shader(&program, frag);
    gl.link_program(&program);

    if gl.get_program_parameter(&program, GL::LINK_STATUS).as_bool().unwrap_or(false) {
        Ok(program)
    } else {
        Err(gl.get_program_info_log(&program).unwrap_or_default())
    }
}

行列演算:3Dの基礎

3Dシーンのすべてのオブジェクトは3つの行列変換を経ます:

                   モデル          ビュー         射影
  ローカル空間 ──────────► ワールド ─────────► カメラ ──────────► スクリーン
  (オブジェクト)   空間         空間          (NDC)

  MVP = Projection * View * Model
行列 目的
Model オブジェクトの位置/回転/スケーリング キューブを45度回転
View カメラの配置 カメラを(0, 2, 5)に置いて原点を見る
Projection 3D → 2D(透視変換) 視野角60度

回転行列

各軸周りの回転にはsinとcosを使用します:

Y軸周りにθ回転:

┌  cos θ   0   sin θ   0 ┐
│    0      1     0     0 │
│ -sin θ   0   cos θ   0 │
└    0      0     0     1

透視投影

透視投影行列は奥行きの錯覚を生み出します — 遠くのオブジェクトほど小さく見えます:

視錐台(視野体積):

        近平面
        ┌───────┐
       /│       │\
      / │       │ \
     /  │       │  \        遠平面
    /   │       │   \    ┌─────────────┐
   / FOV│       │    \   │             │
  /     │ 視点  │     \  │             │
 /      │  *    │      \ │             │
 \      │       │      / │             │
  \     │       │     /  │             │
   \    │       │    /   │             │
    \   └───────┘   /   └─────────────┘
     \             /
      near=0.1    far=100.0

キューブジオメトリ

キューブは8つの頂点、6つの面、12個の三角形(面ごとに2つ)を持ちます:

// キューブ頂点(各面の頂点ごとに位置+法線)
const CUBE_VERTICES: &[f32] = &[
    // 前面 (z = 1)
    -1.0, -1.0,  1.0,   0.0,  0.0,  1.0,  // 位置, 法線
     1.0, -1.0,  1.0,   0.0,  0.0,  1.0,
     1.0,  1.0,  1.0,   0.0,  0.0,  1.0,
    -1.0,  1.0,  1.0,   0.0,  0.0,  1.0,
    // 背面 (z = -1) ...
    // 上面 (y = 1) ...
    // 全6面について同様
];

const CUBE_INDICES: &[u16] = &[
    0,  1,  2,    0,  2,  3,   // 前面
    4,  5,  6,    4,  6,  7,   // 背面
    8,  9,  10,   8,  10, 11,  // 上面
    12, 13, 14,   12, 14, 15,  // 底面
    16, 17, 18,   16, 18, 19,  // 右面
    20, 21, 22,   20, 22, 23,  // 左面
];

GPUへのデータアップロード

fn create_buffer(gl: &GL, data: &[f32]) -> web_sys::WebGlBuffer {
    let buffer = gl.create_buffer().unwrap();
    gl.bind_buffer(GL::ARRAY_BUFFER, Some(&buffer));

    // f32スライスをWebGL用の生バイトに変換
    unsafe {
        let view = js_sys::Float32Array::view(data);
        gl.buffer_data_with_array_buffer_view(
            GL::ARRAY_BUFFER, &view, GL::STATIC_DRAW,
        );
    }

    buffer
}

レンダリングループ

use std::cell::RefCell;
use std::rc::Rc;

fn start_render_loop(gl: GL, program: WebGlProgram) {
    let f = Rc::new(RefCell::new(None));
    let g = f.clone();
    let angle = Rc::new(RefCell::new(0.0f64));

    *g.borrow_mut() = Some(Closure::wrap(Box::new(move || {
        let mut a = angle.borrow_mut();
        *a += 0.01;  // 回転

        // クリア
        gl.clear(GL::COLOR_BUFFER_BIT | GL::DEPTH_BUFFER_BIT);

        // 新しい回転でモデル行列を更新
        let model = multiply(&rotate_y(*a), &rotate_x(*a * 0.7));
        let model_flat: Vec<f32> = model.iter().flatten().map(|&x| x as f32).collect();

        let loc = gl.get_uniform_location(&program, "uModel");
        gl.uniform_matrix4fv_with_f32_array(loc.as_ref(), false, &model_flat);

        // 描画
        gl.draw_elements_with_i32(GL::TRIANGLES, 36, GL::UNSIGNED_SHORT, 0);

        // 次のフレームをリクエスト
        request_animation_frame(f.borrow().as_ref().unwrap());
    }) as Box<dyn FnMut()>));

    request_animation_frame(g.borrow().as_ref().unwrap());
}

拡散ライティング

最もシンプルなライティングモデルは、面の法線とライト方向の角度に基づいて明るさを計算します:

ライト ──────────►
          θ \    │ 法線
             \   │
              \  │
               \ │
    ────────────\│────────── 面
                 *

intensity = max(dot(normal, light_dir), 0.0)
            = max(cos(θ), 0.0)

θ = 0°のとき  → intensity = 1.0(完全に照らされる)
θ = 90°のとき → intensity = 0.0(エッジオン、暗い)
θ > 90°のとき → intensity = 0.0(反対向き)

完全に黒い影を防ぐために環境光を追加します:

final_color = (ambient + diffuse_intensity) * object_color * light_color

Wasm + WebGLのパフォーマンスヒント

テクニック メリット
行列計算をRustで実行 複雑な数学処理がJSより高速
描画コールのバッチ処理 GPU状態変更の削減
Float32Array::viewの使用 GPUへのゼロコピーデータ転送
uniform更新の最小化 フレームごとに変更分のみ更新
インデックスバッファの使用 頂点の重複を削減
裏面カリング gl.enable(GL::CULL_FACE) — 見えない三角形をスキップ

まとめ

  1. WebGLは低レベルAPI — Rust/Wasmは数学処理(行列演算、物理、プロシージャル生成)に優れている
  2. 頂点シェーダは3D点を変換し、フラグメントシェーダはピクセルを着色する — どちらもGPU上で実行
  3. MVP行列 = Projection * View * Model — ローカル座標をスクリーン座標に変換する
  4. 透視投影は奥行きの錯覚を生み出し、LookAtはカメラを配置する
  5. 拡散ライティング = dot(normal, light_direction) — シンプルだが効果的
  6. WasmからWebGLバッファへのゼロコピーデータアップロードにはjs_sys::Float32Array::viewを使用する
  7. レンダリングループはクロージャ経由でrequestAnimationFrameを使用する — Closureがdropされないように保存すること

試してみる