backend/canvas チュートリアル

このチュートリアルでは、geometry3d のシーンを Web ページに載せる方法を説明します。JavaScript ターゲットのパッケージを用意し、<canvas> に 1 フレーム描き、requestAnimationFrame でアニメーションさせ、独自の色を使う版を書きます。

クイックスタート

このバックエンドには js ターゲットと rabbita の DOM バインディングが必要です。

moon add Luna-Flow/geometry3d@0.5.1
moon add Luna-Flow/linear-algebra@0.4.2
moon add moonbit-community/rabbita@0.12.4

moon.pkg でブラウザ用の実行可能パッケージを宣言します。

import {
  "Luna-Flow/geometry3d/core",
  "Luna-Flow/geometry3d/view",
  "Luna-Flow/geometry3d/frontend",
  "Luna-Flow/geometry3d/backend/canvas",
  "moonbit-community/rabbita/dom",
}

supported_targets = "js"

pkgtype(kind: "executable")

id が scene の要素に 1 フレーム描きます。

fn main {
  let canvas = @dom.document()
    .get_element_by_id("scene")
    .to_option()
    .unwrap()
    .to_html_canvas_element()
    .unwrap()
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(480, 360), 300.0),
  )
  let scene = @frontend.Scene::single(
    @core.torus_mesh(1.6, 0.55, 32, 18),
    @core.Transform3::rotation(1.0, 0.4, 0.0),
    @frontend.Light::default(),
  )
  let list = @frontend.build_draw_list(scene, view)
  @canvas.render_canvas(canvas, list, @canvas.CanvasRenderConfig::sized(480, 360))
}

moon build --target js でビルドし、<canvas id="scene"></canvas> を含むページから生成された JavaScript ファイルを読み込みます。

<!doctype html>
<canvas id="scene"></canvas>
<script type="module" src="./main.js"></script>

ページには濃い青の背景にシアンのフラットシェーディングされたトーラスが表示されます。リポジトリの just canvas-build レシピを見ると、moon が出力をどこに置くか(_build/js/debug/build/<package>/<package>.js)がわかり、それを index.html の隣にコピーしています。

よくある作業

requestAnimationFrame でアニメーションする

再描画のたびにコールバックするようブラウザに頼み、渡されたタイムスタンプ(ミリ秒)のシーンを描画して、もう一度頼みます。

fn start_animation(context : @dom.CanvasRenderingContext2D) -> Unit {
  let config = @canvas.CanvasRenderConfig::sized(480, 360)
  let view = @frontend.RenderView::perspective(
    @view.Camera3::default(4.5),
    @view.PerspectiveProjection::new(@view.Viewport::new(480, 360), 300.0),
  )
  let window = @dom.window()
  fn frame(timestamp : Double) -> Unit {
    let angle = timestamp * 0.001
    let scene = @frontend.Scene::single(
      @core.cube_mesh(1.0),
      @core.Transform3::rotation(0.7 * angle, angle, 0.25 * angle),
      @frontend.Light::default(),
    )
    @canvas.render_scene(context, scene, view, config)
    ignore(window.request_animation_frame(frame))
  }
  ignore(window.request_animation_frame(frame))
}

キャンバスの width と height 属性を設定サイズにしてから、canvas.get_context("2d").to0().unwrap() でコンテキストを一度だけ取得します。1 フレームだけなら render_canvas が両方を行います。

レンズで構図を決める

手で調整したスケールではなく焦点距離から構図が決まるよう、ScientificCamera を使います。640 × 480 のキャンバスとフルサイズセンサーでは、35 mm レンズの垂直方向の視野は約 38° です。

fn lens_view(focal_mm : Double) -> @frontend.RenderView {
  let camera = @view.ScientificCamera::new(
    @view.Camera3::default(6.2),
    @view.SensorSpec::full_frame(),
    @view.LensSpec::new(focal_mm),
    @view.WorldUnit::unitless(),
  )
  @frontend.RenderView::scientific(camera, @view.Viewport::new(640, 480))
}

test "lens view" {
  let degrees = @view.LensSpec::new(35.0).vertical_fov(@view.SensorSpec::full_frame()) * 180.0 / @math.PI
  inspect(degrees.round(), content="38")
  inspect(lens_view(35.0).projection.scale, content="700")
}

独自の色で塗る

CanvasRenderConfig は色を固定しており、そのフィールドは自分のパッケージからは設定できません。別のパレットが必要なら、フロントエンドで自分でラスタライズしてピクセルを塗ります。

fn paint_amber(context : @dom.CanvasRenderingContext2D, list : @frontend.DrawList, w : Int, h : Int) -> Unit {
  context.set_fill_style(@js.Union3::from0("rgb(20, 10, 0)"))
  context.fill_rect(0.0, 0.0, w.to_double(), h.to_double())
  let luma = @frontend.draw_list_to_luma(list, w, h)
  for y in 0..<h {
    for x in 0..<w {
      if luma.depth_at(x, y) < @frontend.LUMA_FAR_DEPTH * 0.5 {
        let v = luma.get(x, y)
        let r = (255.0 * v).round().to_int()
        let g = (176.0 * v).round().to_int()
        context.set_fill_style(@js.Union3::from0("rgb(\{r}, \{g}, 0)"))
        context.fill_rect(x.to_double(), y.to_double(), 1.0, 1.0)
      }
    }
  }
}

この簡単な例ではピクセルごとに 1 回呼び出しますが、バックエンドは設計で説明しているとおり、等しい隣接ピクセルをランにまとめてはるかに少ない回数で済ませます。@js.Union3 は moonbit-community/rabbita/js のインポートで使えるようになります。

さらに進んで

ページからシーンを切り替える

リポジトリの demo_canvas パッケージは、選択中のシーンを Ref に保持し、<select> 要素の change イベントで更新し、アニメーションのコールバックの中で読みます。詳しくは demo_canvas チュートリアルを見てください。

性能

各フレームでキャンバス全体を MoonBit でラスタライズし、同じ濃淡のランごとに fillRect を 1 回発行します。フラットシェーディングのシーンではランが長いので、640 × 480 なら現在のブラウザで滑らかにアニメーションします。描画コストはキャンバスの面積とともに増えるので、時間を節約するには小さなキャンバスに描いて CSS で拡大してください(image-rendering: pixelated でピクセルがくっきり保たれます)。

よくある落とし穴

  • ターゲットの誤り。 このパッケージは js 向けにしかビルドできません。インポートするすべてのパッケージに supported_targets = "js" を加えないと、ほかのターゲットでのビルドが失敗します。
  • 2 つのサイズ。 投影のビューポート、CanvasRenderConfig、canvas 要素の width と height 属性には同じサイズを使ってください。CSS のサイズだけではビットマップが引き伸ばされるだけです。
  • 2D コンテキストがない。 getContext("2d") が失敗すると(たとえばすでに WebGL に使われたキャンバス)、render_canvas は中断します。
  • 黒は背景ではない。 光源に背を向けた面は黒く塗られ、背景色とは区別されます。
  • 読み取り専用の設定。 CanvasRenderConfig で色や濃淡の段階数は変えられません。上のように輝度バッファを自分で塗ってください。

次のステップ

  • Canvas API には設定と描画関数が載っています。
  • シェーディングとランレングスによる塗りつぶしは Canvas の設計で説明しています。
  • ピクセルではなくベクターのポリゴンを保持する GSAP SVG バックエンドと比べてみてください。