cgltf-go/cgltf.go¶
:material-github: View on GitHub · 320 lines · live source, included at build time
// Package cgltfgo provides a pure-Go loader for glTF 2.0 3D models, with
// support for parsing multiple assets concurrently. It is a port of the
// cgltf C library (github.com/jkuhlmann/cgltf).
package cgltfgo
import (
"context"
"encoding/json"
"errors"
"fmt"
"runtime"
"sync"
)
// GLTF represents a parsed glTF 3D model.
type GLTF struct {
Asset Asset `json:"asset"`
Scene int `json:"scene,omitempty"`
Scenes []Scene `json:"scenes,omitempty"`
Nodes []Node `json:"nodes,omitempty"`
Meshes []Mesh `json:"meshes,omitempty"`
Accessors []Accessor `json:"accessors,omitempty"`
BufferViews []BufferView `json:"bufferViews,omitempty"`
Buffers []Buffer `json:"buffers,omitempty"`
Materials []Material `json:"materials,omitempty"`
}
// Asset contains metadata about the glTF asset.
type Asset struct {
Version string `json:"version"`
Generator string `json:"generator,omitempty"`
Copyright string `json:"copyright,omitempty"`
MinVersion string `json:"minVersion,omitempty"`
}
// Scene represents a scene in the glTF model.
type Scene struct {
Name string `json:"name,omitempty"`
Nodes []int `json:"nodes,omitempty"`
}
// Node represents a node in the scene graph.
type Node struct {
Name string `json:"name,omitempty"`
Mesh int `json:"mesh,omitempty"`
Children []int `json:"children,omitempty"`
Translation []float64 `json:"translation,omitempty"`
Rotation []float64 `json:"rotation,omitempty"`
Scale []float64 `json:"scale,omitempty"`
Matrix []float64 `json:"matrix,omitempty"`
}
// Mesh represents a 3D mesh.
type Mesh struct {
Name string `json:"name,omitempty"`
Primitives []Primitive `json:"primitives"`
}
// Primitive represents a mesh primitive.
type Primitive struct {
Attributes map[string]int `json:"attributes"`
Indices int `json:"indices,omitempty"`
Material int `json:"material,omitempty"`
Mode int `json:"mode,omitempty"`
}
// Accessor defines how to read data from a buffer view.
type Accessor struct {
BufferView int `json:"bufferView,omitempty"`
ByteOffset int `json:"byteOffset,omitempty"`
ComponentType int `json:"componentType"`
Count int `json:"count"`
Type string `json:"type"`
Max []float64 `json:"max,omitempty"`
Min []float64 `json:"min,omitempty"`
}
// BufferView represents a view into a buffer.
type BufferView struct {
Buffer int `json:"buffer"`
ByteOffset int `json:"byteOffset,omitempty"`
ByteLength int `json:"byteLength"`
ByteStride int `json:"byteStride,omitempty"`
Target int `json:"target,omitempty"`
}
// Buffer represents a data buffer.
type Buffer struct {
ByteLength int `json:"byteLength"`
URI string `json:"uri,omitempty"`
}
// Material represents a material definition.
type Material struct {
Name string `json:"name,omitempty"`
PBRMetallicRoughness *PBRMetallicRoughness `json:"pbrMetallicRoughness,omitempty"`
EmissiveFactor []float64 `json:"emissiveFactor,omitempty"`
AlphaMode string `json:"alphaMode,omitempty"`
DoubleSided bool `json:"doubleSided,omitempty"`
}
// PBRMetallicRoughness contains PBR material parameters.
type PBRMetallicRoughness struct {
BaseColorFactor []float64 `json:"baseColorFactor,omitempty"`
MetallicFactor float64 `json:"metallicFactor,omitempty"`
RoughnessFactor float64 `json:"roughnessFactor,omitempty"`
}
// Parse parses glTF JSON data.
func Parse(data []byte) (*GLTF, error) {
if len(data) == 0 {
return nil, errors.New("empty glTF data")
}
var gltf GLTF
if err := json.Unmarshal(data, &gltf); err != nil {
return nil, fmt.Errorf("failed to parse glTF: %w", err)
}
// Validate required fields
if gltf.Asset.Version == "" {
return nil, errors.New("missing required field: asset.version")
}
return &gltf, nil
}
// ValidateGLTF performs structural sanity checks on a glTF model: the asset
// version, the default-scene index, scene/node graph references, and node mesh
// references. It deliberately does NOT verify the full buffer graph (accessor →
// bufferView → buffer) or material references, so a passing result is not a
// guarantee of complete referential integrity — callers that index into those
// arrays must still bounds-check.
func ValidateGLTF(gltf *GLTF) error {
if gltf == nil {
return errors.New("nil glTF")
}
// Check version
if gltf.Asset.Version != "2.0" {
return fmt.Errorf("unsupported glTF version: %s (only 2.0 supported)", gltf.Asset.Version)
}
// Validate the default-scene reference. `scene` is optional in glTF: a model
// with no scenes (e.g. a mesh library) is valid as long as it doesn't point
// at a non-existent scene. Note the Go zero value can't distinguish an
// omitted `scene` from `scene: 0`.
if len(gltf.Scenes) > 0 {
if gltf.Scene < 0 || gltf.Scene >= len(gltf.Scenes) {
return fmt.Errorf("invalid scene index: %d", gltf.Scene)
}
} else if gltf.Scene != 0 {
return fmt.Errorf("scene index %d set but no scenes are defined", gltf.Scene)
}
// Scene node lists are explicit references into Nodes.
for si, scene := range gltf.Scenes {
for _, n := range scene.Nodes {
if n < 0 || n >= len(gltf.Nodes) {
return fmt.Errorf("scene %d references invalid node: %d", si, n)
}
}
}
// Validate node references (mesh index and child node indices).
for i, node := range gltf.Nodes {
// mesh is optional; reject negative and out-of-range, allowing the
// "omitted" zero only when no meshes are defined.
if len(gltf.Meshes) > 0 {
if node.Mesh < 0 || node.Mesh >= len(gltf.Meshes) {
return fmt.Errorf("node %d references invalid mesh: %d", i, node.Mesh)
}
} else if node.Mesh != 0 {
return fmt.Errorf("node %d references mesh %d but no meshes are defined", i, node.Mesh)
}
for _, c := range node.Children {
if c < 0 || c >= len(gltf.Nodes) {
return fmt.Errorf("node %d references invalid child: %d", i, c)
}
}
}
return nil
}
// GetMeshCount returns the number of meshes in the model.
func (g *GLTF) GetMeshCount() int {
return len(g.Meshes)
}
// GetNodeCount returns the number of nodes in the model.
func (g *GLTF) GetNodeCount() int {
return len(g.Nodes)
}
// GetMesh returns a mesh by index.
func (g *GLTF) GetMesh(index int) (*Mesh, error) {
if index < 0 || index >= len(g.Meshes) {
return nil, fmt.Errorf("invalid mesh index: %d", index)
}
return &g.Meshes[index], nil
}
// MaxBatchSize caps the number of files ParseBatch will accept in a single
// call. Many small-but-valid glTF files handed to one batch call can still
// exhaust memory/CPU in aggregate. Set it to 0 to disable the guard.
var MaxBatchSize = 10_000
// gltfBatchJob is one unit of work for ParseBatch's worker pool.
type gltfBatchJob struct {
data []byte
index int
}
// gltfBatchResult is one worker's output for ParseBatch's worker pool.
type gltfBatchResult struct {
gltf *GLTF
err error
index int
}
// parseBatchWorker pulls jobs from jobs, parses each via Parse, and sends the
// result on results. It returns once jobs is drained and closed, or ctx is
// canceled.
func parseBatchWorker(ctx context.Context, jobs <-chan gltfBatchJob, results chan<- gltfBatchResult) {
for {
// Check cancellation first: a bare select races between a ready job
// and ctx.Done() (Go picks randomly), so an already-canceled context
// would only be honored intermittently.
if ctx.Err() != nil {
return
}
select {
case <-ctx.Done():
return
case work, ok := <-jobs:
if !ok {
return
}
gltf, err := Parse(work.data)
results <- gltfBatchResult{gltf: gltf, err: err, index: work.index}
}
}
}
// ParseBatch parses multiple glTF files concurrently.
func ParseBatch(ctx context.Context, dataList [][]byte) ([]*GLTF, error) {
if len(dataList) == 0 {
return nil, errors.New("empty data list")
}
if MaxBatchSize > 0 && len(dataList) > MaxBatchSize {
return nil, fmt.Errorf("batch of %d files exceeds the %d-file limit (adjust MaxBatchSize)",
len(dataList), MaxBatchSize)
}
numWorkers := runtime.NumCPU()
if numWorkers > len(dataList) {
numWorkers = len(dataList)
}
dataChan := make(chan gltfBatchJob, len(dataList))
resultChan := make(chan gltfBatchResult, len(dataList))
var wg sync.WaitGroup
// Start workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
parseBatchWorker(ctx, dataChan, resultChan)
}()
}
// Send work
go func() {
for i, data := range dataList {
select {
case <-ctx.Done():
close(dataChan)
return
case dataChan <- gltfBatchJob{data: data, index: i}:
}
}
close(dataChan)
}()
// Collect results
go func() {
wg.Wait()
close(resultChan)
}()
results := make([]*GLTF, len(dataList))
for res := range resultChan {
if ctx.Err() != nil {
return nil, ctx.Err()
}
if res.err != nil {
return nil, fmt.Errorf("failed to parse glTF at index %d: %w", res.index, res.err)
}
results[res.index] = res.gltf
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
return results, nil
}
// SerializeGLTF converts a glTF model to JSON.
func SerializeGLTF(gltf *GLTF) ([]byte, error) {
if gltf == nil {
return nil, errors.New("nil glTF")
}
data, err := json.Marshal(gltf)
if err != nil {
return nil, fmt.Errorf("failed to serialize glTF: %w", err)
}
return data, nil
}
// SerializeGLTFIndent converts a glTF model to indented JSON.
func SerializeGLTFIndent(gltf *GLTF) ([]byte, error) {
if gltf == nil {
return nil, errors.New("nil glTF")
}
data, err := json.MarshalIndent(gltf, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to serialize glTF: %w", err)
}
return data, nil
}