Rgltf

Rgltf is a dependency-free Ruby library for loading, inspecting, building, and writing glTF 2.0 JSON and GLB files. Its primary accessor API returns packed, little-endian binary strings suitable for direct GPU upload, while Ruby arrays remain available for inspection and CPU-side processing.

Features

  • Load glTF 2.0 JSON and GLB from paths, IO objects, or strings.
  • Resolve external files and data URIs with path-traversal protection.
  • Read interleaved, sparse, normalized, and matrix accessors.
  • Return GPU-ready accessor data without converting it to Ruby objects.
  • Traverse scenes and calculate node world transforms.
  • Build and write GLB, external-resource glTF, and embedded glTF.
  • Validate documents and preserve unknown optional extensions.
  • Parse four commonly used Khronos extensions out of the box.

Ruby 3.1 or newer is required.

Installation

Add Rgltf to your bundle:

bundle add rgltf

Alternatively, add it to your Gemfile and run bundle install:

gem "rgltf"

For a standalone installation:

gem install rgltf

Usage

Require the gem before using its public API:

require "rgltf"

Loading documents

document = Rgltf.load("assets/model.glb")
document = Rgltf.load("assets/model.gltf")
document = Rgltf.load_glb(binary_string)
document = Rgltf.load_json(json_string, base_dir: "assets")

External resources are resolved relative to the glTF file. Paths that escape the base directory are rejected. Buffers are loaded lazily by default; pass lazy: false to load them immediately.

Pass validate: true to validate while loading:

document = Rgltf.load("assets/model.glb", validate: true)

All public failures inherit from Rgltf::Error. Invalid documents raise Rgltf::ValidationError. Unknown required extensions raise Rgltf::UnsupportedExtensionError; pass strict_extensions: false to record a warning instead.

Reading GPU-ready accessor data

primitive = document.meshes.first.primitives.first
positions = primitive.attributes.fetch(:POSITION)

gpu_bytes = positions.packed       # frozen ASCII-8BIT String
format = positions.vertex_format   # for example, :float32x3
values = positions.to_a            # [[x, y, z], ...]
indices = primitive.indices.packed_as_u32

packed resolves interleaved buffer views, sparse accessors, and matrix column padding. It preserves normalized integer components; to_a converts normalized values to floats. Call document.release_buffers! after uploading the data when the source buffers are no longer needed.

References in the source JSON are resolved to objects while loading:

document.default_scene.nodes.each(&:traverse)

document.each_mesh_instance do |node, mesh, world_matrix|
  # world_matrix is a 16-element, column-major Ruby Array
end

Building and writing documents

document = Rgltf::Builder.build do |builder|
  builder.asset(generator: "my exporter")

  positions = builder.accessor(:VEC3, :f32, positions_binary, min_max: true)
  indices = builder.accessor(:SCALAR, :u16, indices_binary, target: :element_array)

  texture = builder.texture(
    image: File.binread("albedo.png"),
    mime_type: "image/png",
    sampler: {wrap_s: :repeat, mag_filter: :linear}
  )
  material = builder.material(
    name: "body",
    base_color_texture: texture,
    metallic_factor: 0.0,
    roughness_factor: 0.8
  )

  mesh = builder.mesh(name: "hull") do |mesh_builder|
    mesh_builder.primitive(
      attributes: {POSITION: positions},
      indices: indices,
      material: material
    )
  end

  root = builder.node(name: "root", mesh: mesh)
  builder.scene(nodes: [root], default: true)
end

document.write_glb("model.glb")
document.write_gltf("model.gltf")
document.write_gltf("embedded.gltf", embed: true)

Builder buffer views are aligned to four bytes. Writer validation is enabled by default and can be disabled with validate: false.

Use explicit buffer views for interleaved records and create sparse accessors without constructing dense base buffers:

view = builder.buffer_view(vertex_records, byte_stride: 24, target: :array_buffer)
positions = builder.accessor_from_view(
  :VEC3, :f32, buffer_view: view, count: vertex_count
)
normals = builder.accessor_from_view(
  :VEC3, :f32, buffer_view: view, count: vertex_count, byte_offset: 12
)

weights = builder.sparse_accessor(
  :SCALAR, :f32,
  count: vertex_count,
  indices: changed_vertex_indices,
  values: changed_weights_binary,
  index_component_type: :u16
)

Texture-info handles retain per-use coordinates, transforms, and extras:

info = builder.texture_info(
  texture,
  tex_coord: 1,
  extensions: {"KHR_texture_transform" => {"offset" => [0.5, 0.0]}}
)
material = builder.material(base_color_texture: info, double_sided: true)

builder.extensions_used("KHR_texture_transform")
builder.extension("VENDOR_scene", {"revision" => 2}, required: true)
builder.extras("exporter" => "example")

Handles belong to the builder that created them. Passing a handle to another builder raises ArgumentError before an invalid reference can be emitted.

Extensions

The default extension registry supports:

  • KHR_materials_unlit
  • KHR_texture_transform, including TextureInfo#transform.uv_matrix
  • KHR_lights_punctual
  • KHR_materials_emissive_strength

Register custom parsers on a separate registry when isolation is needed:

registry = Rgltf::ExtensionRegistry.new
registry.add("VENDOR_example", MyExtension, on: :material)
document = Rgltf.load(path, extensions: registry)

Unknown optional extension payloads remain Hash objects, so documents can be read and written without losing them. A handler's serialize method receives the parsed object when writing. Handlers may also implement decode_primitive to provide external Draco or meshopt decoding.

Handlers that omit defaults should declare the properties they handle, leaving newer unknown properties untouched:

def self.serialize(object, doc:)
  value = object.factor == 1.0 ? {} : {"factor" => object.factor}
  serialize_properties(value, handled_keys: %w[factor])
end

Examples

Runnable examples for building, inspecting, and converting assets are available in examples/.

Development

Clone the repository and install its dependencies:

git clone https://github.com/ydah/rgltf.git
cd rgltf
bundle install

Run the unit and property suites:

bundle exec rake

Property checks use prop_check with shrinking and cover every accessor component/type combination plus 100 generated Builder-to-GLB round trips. Replay a reported case with:

PROP_CHECK_SEED=<seed> bundle exec rspec spec/accessor_property_spec.rb

The Khronos glTF-Sample-Assets corpus is pinned to 2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf:

bundle exec rake sample_assets:fetch
GLTF_SAMPLE_ASSETS="$PWD/tmp/gltf-sample-assets" bundle exec rake sample_assets:verify

Corpus verification covers 334 representations, including 310 fully materialized representations and 24 containers that require an external Draco or meshopt decoder. It also runs a 30-model feature manifest.

The repository additionally provides:

  • benchmark/sample_assets.rb for repeatable load and accessor benchmarks.
  • A performance workflow that rejects median regressions greater than 30%.
  • A manual official glTF Validator workflow for GLB and glTF writer output.
  • spec/fixtures/public_api.json as the machine-readable public API contract.

Contributing

Bug reports and pull requests are welcome on GitHub. Please include a focused test for behavior changes and ensure bundle exec rake passes before submitting a pull request.

License

Rgltf is available as open source under the terms of the MIT License.