2023年02月21日

Google Sheets に 3D レンダリングする

レイマーチングについて勉強していたところ Google Sheets に 3D レンダリングの可能性を感じ、実践してみました。

目次

Google Sheets を 3D レンダリング環境にする

3D レンダリング環境に必要な要素は以下の 2 つです。

  1. 画素をグリッド状に並べたスクリーン
  2. 各々の画素の色を計算するフラグメントシェーダの実装・実行環境

スクリーンは行の高さを 3px 、列の幅を 2px に設定したシートを使用しました。セルを正方形として表示するには、行の高さを列の幅より 1px 大きく設定する必要があるようです。

スクリーンに見立てたシート

フラグメントシェーダは Google Apps Script (GAS) で実装しました。 GAS では Range クラスの setBackgrounds メソッドを用いて Google Sheets のセルの背景色を設定できます。 GAS で画素の色を計算し、スクリーンとして用意したシートの各セルに背景色として設定することで画像をレンダリングします。

const ss = SpreadsheetApp.getActiveSpreadsheet();
const screen = ss.getRangeByName("screen");
const rows = screen.getNumRows();
const columns = screen.getNumColumns();
const colorLines = [];

for (let row = 0; row < rows; ++row) {
  const colorLine = [];

  for (let column = 0; column < columns; ++column) {
    // 画素(セル)の色を計算し 'rgba(0-255, 0-255, 0-255, 0-1)' 形式で取得
    const colorString = render_cell(row, column);
    colorLine.push(colorString);
  }

  colorLines.push(colorLine);
}

// セル範囲に背景色を一括で設定
screen.setBackgrounds(colorLines);

これで、 Google Sheets (+ GAS) 上に 3D レンダリング環境が整いました。

TypeScript でフラグメントシェーダを書く

clasp という Google 製のツールがあり、これを使用するとローカル環境で GAS のコードを記述して Apps Script プロジェクトにアップロードすることができます。 TypeScript を使用して開発する方法 も準備されており、型のある世界で GAS 開発をすることができます。

今回はこの clasp を使用し、 TypeScript でフラグメントシェーダを記述しました。

レイマーチング

レイマーチングは 3D レンダリングアルゴリズムの一つであり、レイ(光線)とオブジェクトとの交差を求めることで 3D シーンを描画する手法です。より具体的には、符号付き距離関数(Signed Distance Function; SDF)でオブジェクトを表現し、 SDF を使用してオブジェクトとの交差を計算します。 SDF は点からオブジェクト表面までの距離を符号を付けて返す関数であり、符号の正はオブジェクトの外側、負はオブジェクトの内側を表します。距離が 0 の場合はオブジェクト表面を表します。

TypeScript で実装すると以下のようになります。実装中に出てくる Vec3 クラスはベクトル計算用に実装したクラスです。また、 EPS, INF は十分小さい値と十分大きい値を表す定数です。関数の結果はレイとオブジェクト表面との交点であり、交点の座標からオブジェクトの描画色を決めていきます。

/**
 * @param count 繰り返しの最大回数
 * @param point カメラの位置
 * @param direction レイの向き
 * @param sdf SDF
 */
function ray_marching(
  count: number,
  point: Readonly<Vec3>,
  direction: Readonly<Vec3>,
  sdf: (point: Readonly<Vec3>) => number
): Vec3 {
  let d = 0;
  for (let i = 0; i < count; ++i) {
    const dd = sdf(point.add(direction.mul(d)));
    if (Math.abs(dd) < EPS || Math.abs(d) >= INF) break;
    d += dd;
  }
  const intersection = point.add(direction.mul(d));
  return intersection;
}

SDF の操作

複雑なオブジェクトを SDF で表現するには、オブジェクトのパーツを簡単な SDF で表現し、それらを組み合わせてオブジェクトの SDF を構築すると実装の効率が良いです。パーツを組み合わせるために使う SDF の操作がいくつかあります。

各種操作は以下 URL 先の記事を参考に実装しています。

https://iquilezles.org/articles/distfunctions/

SDF の平行移動

パーツの位置を変えるためには SDF が表現するオブジェクトを平行移動する操作が必要です。

あるオブジェクトの SDF を実装した関数 sdf を考えます。距離の基準点を 3 次元ベクトルとして入力し符号付き距離をスカラとして出力する関数です。

/**
 * @param point 基準点
 * @returns 符号付き距離
 */
function sdf(point: Readonly<Vec3>): number;

この SDF で表されるオブジェクトを 3D 空間上で (x, y, z) だけ平行移動することは、基準点を (-x, -y, -z) だけ平行移動することに等しいです。そのため、 SDF の平行移動は以下で計算されます。

/**
 * @param point 基準点
 * @param offset 平行移動量
 * @returns 平行移動を適用した基準点
 */
function translate(point: Readonly<Vec3>, offset: Readonly<Vec3>): Vec3 {
  // point と offset の差のベクトルを計算する
  return point.sub(offset);
}

平行移動したオブジェクトの SDF は translate を用いて以下の式で計算されます。 sdf 関数を適用する前に平行移動の操作を適用します。

/**
 * @param point 基準点
 * @param offset 平行移動量
 * @returns 符号付き距離
 */
function sdf_translated(point: Readonly<Vec3>, offset: Readonly<Vec3>): number {
  return sdf(translate(point, offset));
}

SDF の回転(クォータニオン版)

パーツの向きを変えるために SDF が表現するオブジェクトを回転する操作も必要です。任意の座標を中心とした回転は原点を中心とした回転と平行移動操作で表現可能なため、原点を中心とした回転のみ考えます。

オブジェクトをある角度だけ回転することは、その SDF の基準点を同じ角度だけ逆向きに回転させることに等しいです。 3 次元ベクトルの回転を計算する方法はいくつかありますが、今回はクォータニオンを用いた計算を採用しました。クォータニオンは四元数とも呼ばれ、複素数を拡張した数の種類の一つです。複素数は実部と虚部の 2 次元からなりますが、クォータニオンは 1 つの実部と 3 次元の虚部の計 4 次元で構成されます。クォータニオンによる 3D 空間上での回転の計算ではクォータニオンの乗法を用います。 4 次元ベクトルのクラス Vec4 の 3 つの軸 xyz を虚部、 1 つの軸 w を実部とみなしたとき、クォータニオンの乗法は以下の関数で計算されます。なお、クォータニオンの乗法は非可換です。

/**
 * @param lhs 左オペランドのクォータニオン
 * @param rhs 右オペランドのクォータニオン
 * @returns 乗算結果のクォータニオン
 */
function quaternion_mul(lhs: Readonly<Vec4>, rhs: Readonly<Vec4>): Vec4 {
  return new Vec4(
    // xyz 軸(虚部)
    lhs.xyz.mul(rhs.w).add(rhs.xyz.mul(lhs.w)).add(lhs.xyz.cross(rhs.xyz)),
    // w 軸(実部)
    lhs.w * rhs.w - lhs.xyz.dot(rhs.xyz)
  );
}

各ベクトルの実部や虚部は、扱いやすさのためスウィズル演算子( lhs.xyzlhs.w )で表現しています。

このクォータニオンの乗法を用いて空間座標の回転を計算するには、座標と回転のそれぞれをクォータニオンによる表現に置き換える必要があります。

まず、空間座標のクォータニオン表現は、以下のように、実部を 0 、虚部を座標値とした表現になります。

/**
 * @param point 空間座標
 * @returns クォータニオンによる空間座標の表現
 */
function point_as_quaternion(point: Readonly<Vec3>): Vec4 {
  // xyz, w の順で 4 次元ベクトルの値を指定
  return new Vec4(point, 0);
}

そして、回転のクォータニオン表現は、以下のように、 2 つのクォータニオンを用いた表現になります。回転軸まわりの回転は共軛な(虚部の符号が互いに逆である) 2 つのクォータニオンのペアで表現されます。

/**
 * @param axis 回転軸
 * @param rad 回転する角度(ラジアン)
 * @returns クォータニオンのペアによる回転の表現
 */
function rotation_as_quaternions(axis: Readonly<Vec3>, rad: number): { lhs: Vec4, rhs: Vec4 } {
  const real: number = Math.cos(0.5 * rad);
  const imag: Vec3 = axis.mul(Math.sin(0.5 * rad));
  return {
    lhs: new Vec4(imag.neg(), real),
    rhs: new Vec4(imag, real)
  };
}

以上の関数を用いて、 SDF の基準点を逆向きに回転する変換を以下で計算します。

/**
 * @param point 基準点
 * @param axis 回転軸
 * @param rad 回転する角度(ラジアン)
 * @returns 回転を適用した基準点
 */
function rotate(point: Readonly<Vec3>, axis: Readonly<Vec3>, rad: number): Vec3 {
  const p = point_as_quaternion(point);
  const { lhs, rhs } = rotation_as_quaternions(axis, -rad);
  const rotated = quaternion_mul(lhs, quaternion_mul(p, rhs));
  return rotated.xyz;
}

回転したオブジェクトの SDF は translate を用いて以下の式で計算されます。平行移動同様、 sdf 関数を適用する前に回転の操作を適用します。

/**
 * @param point 基準点
 * @param axis 回転軸
 * @param rad 回転する角度(ラジアン)
 * @returns 回転を適用した基準点
 */
function sdf_rotated(point: Readonly<Vec3>, axis: Readonly<Vec3>, rad: number): number {
  return sdf(translate(point, offset));
}

SDF の合成

平行移動や回転を施した複数のパーツを合成することで複雑なオブジェクトを表現できます。

SDF において、複数のオブジェクトの合成は SDF の最小値として計算されます。

function sdf_united(
  point: Readonly<Vec3>,
  first_sdf: (point: Readonly<Vec3>) => number,
  rest_sdfs: readonly ((point: Readonly<Vec3>) => number)[]
): number {
  return Math.min(first_sdf(point), ...rest_sdfs.map(sdf => sdf(point)));
}

特にオブジェクトの内側( SDF が負になる領域)でオブジェクトの厳密な符号付き距離とならない場合がありますが、オブジェクトの外側を描画する場合その影響は無視できます。

以上の平行移動、回転、合成の操作を使用してレンダリングするオブジェクトを構成していきます。

描画する被写体

🤔 (thinking face) の Twitter 版をベースに描画するオブジェクトを構成しました。

Twitter 版の thinking face

1f914.svg (c) Twitter (Licensed under CC BY 4.0)

構成するパーツが単純な形であり、 SDF を組みやすそうでしたのでこちらを選びました。

各パーツの配置

thinking face をパーツに分けて、個別に SDF を定義していきます。

各種 SDF についても、以下 URL 先の記事を参考に実装しています。

https://iquilezles.org/articles/distfunctions/

輪郭

まずは thinking face のベースとなる輪郭です。として描画します。

球の SDF の実装は以下の通りです。

/**
 * @param point 基準点
 * @param radius 球の半径
 */
function sdf_sphere(point: Readonly<Vec3>, radius: number): number {
  return point.length() - radius;
}

原点と基準点の距離から半径を引くだけで球を描けます。

GAS でレンダリングした球

半径は 1 、色は 0 から 1 の RGB 値で (1, 0.8, 0.302) として、座標中央 (0, 0, 0) に配置しました。

GAS でレンダリングした輪郭のみの thinking face

続いて、 2 つある眉です。こちらは Capped Torus という、トーラスの一部分を切り出して端点を丸めた形で表現します。

Capped Torus の SDF の実装は以下の通りです。

/**
 * @param point 基準点
 * @param radius トーラスの半径
 * @param thickness トーラスの厚み
 * @param boundary_angle トーラスを切り出す角度
 */
function sdf_capped_torus(point: Readonly<Vec3>, radius: number, thickness: number, boundary_angle: number) {
  const trigonometry = Vec2.trigonometry(radians(boundary_angle));
  const q = new Vec2(point.y, Math.abs(point.x));
  const k = trigonometry.cross(q) > 0 ? q.dot(trigonometry) : q.length();
  return (
    Math.sqrt(point.lengthSq() + radius * radius - 2 * radius * k) -
    thickness
  );
}

trigonometry はトーラスを切り出す角度の余弦と正弦をそれぞれ x, y の値とした 2 次元ベクトルです。 k の定義行の分岐条件である trigonometry.cross(q) > 0 は、トーラスを切り出す角度の外側かどうかを判定します。外側では端点との距離、内側ではトーラスとの距離を計算することでトーラスの一部を切り出した形状が描画されます。

GAS でレンダリングした Capped Torus

左右の眉の Capped Torus はともに半径 0.35 、厚み 0.027 、 切り出す角度 22°、 RGB は (0.4, 0.2706, 0) としました。左眉(向かって右の眉)は (0.21, -0.05, 1) の座標位置に y 軸まわりに -0.2 rad 回転および x 軸まわりに 0.1 rad 回転した向きで配置し、右眉(向かって左の眉)は (-0.2, 0.12, 0.97) の座標位置に y 軸まわりに 0.2 rad 回転および x 軸まわりに 0.2 rad 回転した向きで配置しました。

GAS でレンダリングした輪郭と眉のみの thinking face

Twitter 版 thinking face の目は楕円形をしています。楕円には 2 次元における解析的な距離関数が存在します(やや実装が長いため、以下記事を参照)。

https://iquilezles.org/articles/ellipsedist/

また、平面上のある形の SDF を sdf_2d: (point: Readonly<Vec2>) => number として次の関数を定義すると、元の形に対して丸みのある厚みを持った立体の SDF が得られます。

/**
 * @param point 基準点
 * @param sdf_2d 平面上の形の SDF
 * @param thickness 厚み
 */
function sdf_rounded_shape(point: Readonly<Vec3>, sdf_2d: (point: Readonly<Vec2>) => number, thickness: number) {
  return new Vec2(sdf_2d(point.xy), point.z).length() - thickness;
}

sdf_2d として楕円の SDF を与えれば、厚みを持たせた楕円状の立体の SDF となります。

GAS でレンダリングした厚みを持たせた楕円 GAS でレンダリングした厚みを持たせた楕円の側面

両目の楕円はともに x 軸方向の半径 0.062 、 y 軸方向の半径 0.082 、厚み 0.02 、 RGB は (0.4, 0.2706, 0) としました。左目(向かって右の目)は (0.2, 0.18, 0.98) の座標位置に y 軸まわりに -0.2 rad 回転および x 軸まわりに 0.1 rad 回転した向きで配置し、右目(向かって左の目)は (-0.2, 0.22, 0.97) の座標位置に y 軸まわりに 0.2 rad 回転および x 軸まわりに 0.12 rad 回転した向きで配置しました。

GAS でレンダリングした輪郭と眉と目のみの thinking face

口は眉でも使用した Capped Torus を用いて表現しました。半径 0.35 、厚み 0.027 、 切り出す角度 22°、 RGB は (0.4, 0.2706, 0) としました。左眉(向かって右の眉)は (0.21, -0.05, 1) の座標位置に y 軸まわりに -0.2 rad 回転および x 軸まわりに 0.1 rad 回転した向きで配置し、右眉(向かって左の眉)は (-0.2, 0.12, 0.97) の座標位置に y 軸まわりに 0.2 rad 回転および x 軸まわりに 0.2 rad 回転した向きで配置しました。

GAS でレンダリングした輪郭と眉と目と口のみの thinking face

親指

親指も眉や口で使用した Capped Torus を用いて表現しました。半径 0.16 、厚み 0.05 、切り出す角度 48°、 RGB は (0.9569, 0.5647, 0.0471) としました。 (-0.4, -0.2, 1.012) の座標位置に x 軸まわりに -0.1 rad 回転および z 軸まわりに 1.7 rad 回転した向きで配置しました。

GAS でレンダリングした輪郭と眉と目と口と親指のみの thinking face

人差し指〜小指

人差し指から小指はで表現します。 SDF の実装は以下です。

/**
 * @param point 基準点
 * @param radius 半径
 * @param length 長さ(の半分)
 */
function sdf_stick(point: Readonly<Vec3>, radius: number, length: number) {
  const q = point.abs();
  q.z = Math.max(q.z - length, 0);
  return sdf_sphere(q, radius);
}

各軸に対して原点対称であるため軸ごとに座標の絶対値をとって正の座標のみを考えます。棒の SDF は、 z 座標が与えられた長さ length より小さいときに z 座標を 0 に置き換えた場合の球の SDF 、 z 座標が length 以上のときに z 座標から length を減じた場合の球の SDF と等価になります。そのため、 z 座標から棒の長さを減じて 0 以上の値に制限してから球の SDF を適用することで棒の SDF が計算できます。

GAS でレンダリングした棒

すべての指で半径は 0.05 、 RGB は (0.9569, 0.5647, 0.0471) 、 z 軸まわりに -0.17 rad 回転した向きとしました。他のパラメータは、人差し指が長さ 0.2 および座標位置 (0, -0.32, 1) 、中指が長さ 0.09 および座標位置 (-0.09, -0.42, 1) 、薬指が長さ 0.09 および座標位置 (-0.1, -0.52, 1) 、小指が長さ 0.09 および座標位置 (-0.12, -0.62, 1) としました。

GAS でレンダリングした輪郭と眉と目と口と指のみの thinking face

手の甲

最後に、手の甲は目の表現にも使用した楕円で表現しました。

x 軸方向の半径 0.13 、 y 軸方向の半径 0.16 、厚み 0.05 、 RGB は (0.9569, 0.5647, 0.0471) とし、 (-0.25, -0.482, 1) の座標位置に z 軸まわりに -0.3 rad 回転した向きで配置しました。

パーツの SDF はこれですべてです。これらの SDF の最小値を計算して合成することで、以下の画像がレンダリングできました。

GAS でレンダリングした thinking face

クォータニオンを使ってオブジェクト全体を回転して描画することも可能です。

GAS でレンダリングした左を向いている thinking face

あとがき

以上、すべて Google Sheets と GAS 上で実装したレイマーチングによる thinking face の描画でした。

本来シェーダを書くべき場所は例えば GLSL 、動かすべき場所は GPU であり、それに比べると当然ながら描画は遅くなります。解像度を粗く設定しなければ GAS の実行時間制限(30 秒)に引っ掛かってしまいます。

しかしながら、実際に GAS で書いてみると思いの外メリットになりそうな点もありました。一つは、 Google Sheets から簡単にデータを読めるため、描画したいモデルのパラメータをシートに置いて管理できます。もう一つは、言語として GAS (JavaScript) もしくは TypeScript を使うため関数が第一級オブジェクトになっています。 GLSL の場合、関数は第一級オブジェクトではなく、関数ポインタも利用できません。レイマーチングでは様々な立体を関数として表現するため、関数を変数に代入できたり他の関数の引数に渡せたりする方がシーンの構築が楽になります。

シェーダを学ぶプレイグラウンドの場として Google Sheets は意外と適しているのかもしれません。

RELATED POSTS