OSSのglTFライブラリにPull Requestを出した件について

この記事は[クラスター Advent Calendar 2021](https://qiita.com/advent-calendar/2021/cluster)の12/3の分です。
昨日は [hattori88](https://qiita.com/hattori88) さんの 「[clusterに暮らすキャラクターAIについて思うことと、つくり方 ( Living AI in cluster )](https://qiita.com/hattori88/items/29781a8361ab830cd9e7)」でした。メタバース上でのキャラクターAI…とても興味ぶかいですね。

今年の7月からクラスターに所属している[neguse](https://twitter.com/neguse)と申します。
この記事では以下のPull Requestを解説します。

https://github.com/qmuntal/gltf/pull/42

#背景

クラスターにはGoでglTFを扱う処理が存在しており、そのために[qmuntal/gltf](https://github.com/qmuntal/gltf) というライブラリを利用しています。
(便利なライブラリを公開・メンテしていただいてありがとうございます🙏)
今回新機能([AvatarMaker](https://note.com/cluster_official/n/n2eb5600e04ff))を実装するにあたり、このライブラリにglTFのExtras領域に特定の値を入れるとEncode()が失敗する問題が見つかりました。
本Pull Requestではこの問題を修正して、Extrasに任意の値を入れられるようになりました。

#Pull Requestの内容

glTFは3Dコンテンツのためのファイルフォーマットで、ファイルの中にJSONを含みます。
glTFでは一部のJSONフィールドは省略可能で、その場合デフォルト値が使われるようになっています。
qmuntal/gltfではデフォルト値と同じ値が設定されているフィールドは出力に含めないようになっています。

もともとの処理ではフィールドを削除するため、いったんJSONにエンコードしたあと、デフォルト値 もしくは ゼロ値が設定されているフィールドをbytes.Replace()で削除するという方法をとっていました。

https://github.com/qmuntal/gltf/blob/6c59e8324d6bed73c8c875ebb608a6e3fba72f8b/encode.go#L176-L199

https://github.com/qmuntal/gltf/blob/6c59e8324d6bed73c8c875ebb608a6e3fba72f8b/encode.go#L360-L368

この削除処理に問題があり、Extrasフィールド(glTF仕様としては何でも入れられる)にデフォルト値と同じバイト列が入っている場合に本来削除したいフィールドと別の領域が削除されてしまい、その結果としてJSONとしてinvalidな出力が得られることがありました。

変更後の処理ではstructをjson.Marshal()する際、元のstructをembedした、削除されうるフィールドをポインタ型かつomitemptyとした別のstructを用意して、そちらをjson.Marshal()するようにしました。デフォルト値 もしくは ゼロ値であればポインタ型のフィールドをnilのままとすることで、Marshal()の出力には含まれないようになっています。

https://github.com/neguse/gltf/blob/3dda652ba7b0ef50ffc90879730447193926ff6c/encode.go#L174-L195

#詳細の説明

まずPull Requestを作る際、API互換性を維持することを考えました。
具体的には、もともとqmuntal/gltfが公開していたtype Nodeなどのstructを直接変更してフィールドをポインタ+omitempty化できれば手っ取り早かったのですが、この方法はライブラリを利用している既存コードに影響があるため、とりませんでした。

次に、json.Marshal()やGoの仕様についてです。
json.Marshal()はembedded field(anonymous struct field)がある場合、そのフィールドが入れ子の外側のstructのフィールドであるかのようにMarshalされます。またその際のルールは基本的にGoのvisibility ruleに従います。(`json`タグの扱いに関する追加ルールがありますが)

https://pkg.go.dev/encoding/json#Marshal

> Anonymous struct fields are usually marshaled as if their inner exported fields were fields in the outer struct, subject to the usual Go visibility rules amended as described in the next paragraph.

Goの言語仕様では複数の同名フィールドがある場合、最もdepthが浅いフィールド1つが選ばれるようになっています。(同じdepthのフィールドが複数あったらNG)

https://go.dev/ref/spec#Struct_types

> A field or method f of an embedded field in a struct x is called promoted if x.f is a legal selector that denotes that field or method f.
> Promoted fields act like ordinary fields of a struct except that they cannot be used as field names in composite literals of the struct.

https://go.dev/ref/spec#Selectors

> For a value x of type T or *T where T is not a pointer or interface type, x.f denotes the field or method at the shallowest depth in T where there is such an f. If there is not exactly one f with shallowest depth, the selector expression is illegal.

例えば以下のようになります。

```go
package main

import "fmt"

type Inner struct {
	I int
}

type Inner2 struct {
	I int
}

type S struct {
	Inner
}

type S2 struct {
	Inner
	Inner2
}

type S3 struct {
	Inner
	I int
}

func main() {
	var s S
	var s2 S2
	var s3 S3

	fmt.Println(s.I)  // OK, s.IはInner.Iがpromoteされたもの
	fmt.Println(s2.I) // NG, Inner.IとInner2.Iが同じdepthでexactly oneでなくなるためillegal
	fmt.Println(s3.I) // OK, Inner.IよりS3.Iの方がdepthが低いためS3.Iが使われる
}
```

以上の説明をふまえて、Pull Requestのコードを再度みてみます。

https://github.com/neguse/gltf/blob/3dda652ba7b0ef50ffc90879730447193926ff6c/encode.go#L174-L195

```go
func (n *Node) MarshalJSON() ([]byte, error) {
	type alias Node
	tmp := &struct {
		Matrix      *[16]float32 `json:"matrix,omitempty"`                                          // A 4x4 transformation matrix stored in column-major order.
		Rotation    *[4]float32  `json:"rotation,omitempty" validate:"omitempty,dive,gte=-1,lte=1"` // The node's unit quaternion rotation in the order (x, y, z, w), where w is the scalar.
		Scale       *[3]float32  `json:"scale,omitempty"`
		Translation *[3]float32  `json:"translation,omitempty"`
		*alias
	}{
		alias: (*alias)(n),
	}
	if n.Matrix != DefaultMatrix && n.Matrix != emptyMatrix {
		tmp.Matrix = &n.Matrix
	}
	if n.Rotation != DefaultRotation && n.Rotation != emptyRotation {
		tmp.Rotation = &n.Rotation
	}
	if n.Scale != DefaultScale && n.Scale != emptyScale {
		tmp.Scale = &n.Scale
	}
	if n.Translation != DefaultTranslation {
		tmp.Translation = &n.Translation
	}
	return json.Marshal(tmp)
}

type Node struct {
	Extensions  Extensions  `json:"extensions,omitempty"`
	Extras      interface{} `json:"extras,omitempty"`
	Name        string      `json:"name,omitempty"`
	Camera      *uint32     `json:"camera,omitempty"`
	Children    []uint32    `json:"children,omitempty" validate:"omitempty,unique"`
	Skin        *uint32     `json:"skin,omitempty"`
	Matrix      [16]float32 `json:"matrix"` // A 4x4 transformation matrix stored in column-major order.
	Mesh        *uint32     `json:"mesh,omitempty"`
	Rotation    [4]float32  `json:"rotation" validate:"omitempty,dive,gte=-1,lte=1"` // The node's unit quaternion rotation in the order (x, y, z, w), where w is the scalar.
	Scale       [3]float32  `json:"scale"`
	Translation [3]float32  `json:"translation"`
	Weights     []float32   `json:"weights,omitempty"` // The weights of the instantiated Morph Target.
}
```

tmp変数に含まれるstructはMatrix, Rotation, Scale, Translationというフィールドを含み、元のtype Node(をaliasしたもの)をembedしています。
上記で説明したdepthが浅い方のフィールドが使われるルールに従って、Matrix, Rotation, Scale, Translationの4つのフィールドはtype Nodeに含まれるものでなくtmp変数に含まれるもの(ポインタ型かつomitempty)がjson.Marshal()で出力されることになります。またMatrix, Rotation, Scale, Translation以外のフィールドについてはtype Nodeに含まれるフィールドがそのままpromoteされてjson.Marshal()の出力に現れます。

このようにして、元々のtype Nodeに手を入れることなく特定のフィールドをjson.Marshal()の出力から削除することができました。この方法を応用すると、特定のフィールドを出力から削除するだけでなく、出力時に型を変えることもできそうです。

なお、本Pull Requestは作成後 **46分** という爆速でマージされました。
重ね重ね、ライブラリの開発・メンテをされている[qmuntal](https://github.com/qmuntal)さんに感謝いたします。

#まとめ

この記事ではPull Requestの説明を通して、structの特定のフィールドをjson.Marshal()の出力から消すためにembedを使う方法を説明しました。

明日は[ねおりん](https://qiita.com/noir_neo)さんの「たぶん揚力の話を書きます」です。空、飛びたいですね。