rgltf 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +270 -0
  4. data/Rakefile +10 -0
  5. data/benchmark/accessors.rb +26 -0
  6. data/benchmark/compare.rb +28 -0
  7. data/benchmark/sample_assets.rb +61 -0
  8. data/examples/README.md +23 -0
  9. data/examples/build_triangle.rb +40 -0
  10. data/examples/convert.rb +24 -0
  11. data/examples/inspect.rb +27 -0
  12. data/lib/rgltf/accessor_reader.rb +142 -0
  13. data/lib/rgltf/buffer_resolver.rb +95 -0
  14. data/lib/rgltf/builder/accessor_data.rb +40 -0
  15. data/lib/rgltf/builder/accessors.rb +130 -0
  16. data/lib/rgltf/builder/component_builders.rb +89 -0
  17. data/lib/rgltf/builder/document_properties.rb +32 -0
  18. data/lib/rgltf/builder/materials.rb +132 -0
  19. data/lib/rgltf/builder.rb +176 -0
  20. data/lib/rgltf/document.rb +159 -0
  21. data/lib/rgltf/errors.rb +10 -0
  22. data/lib/rgltf/extension.rb +79 -0
  23. data/lib/rgltf/extensions/khr_lights_punctual.rb +101 -0
  24. data/lib/rgltf/extensions/khr_materials_emissive_strength.rb +29 -0
  25. data/lib/rgltf/extensions/khr_materials_unlit.rb +19 -0
  26. data/lib/rgltf/extensions/khr_texture_transform.rb +42 -0
  27. data/lib/rgltf/glb.rb +71 -0
  28. data/lib/rgltf/properties/accessor.rb +142 -0
  29. data/lib/rgltf/properties/animation.rb +96 -0
  30. data/lib/rgltf/properties/asset.rb +15 -0
  31. data/lib/rgltf/properties/base.rb +45 -0
  32. data/lib/rgltf/properties/buffer.rb +50 -0
  33. data/lib/rgltf/properties/buffer_view.rb +18 -0
  34. data/lib/rgltf/properties/camera.rb +50 -0
  35. data/lib/rgltf/properties/image.rb +48 -0
  36. data/lib/rgltf/properties/material.rb +95 -0
  37. data/lib/rgltf/properties/mesh.rb +67 -0
  38. data/lib/rgltf/properties/node.rb +135 -0
  39. data/lib/rgltf/properties/sampler.rb +35 -0
  40. data/lib/rgltf/properties/scene.rb +14 -0
  41. data/lib/rgltf/properties/skin.rb +25 -0
  42. data/lib/rgltf/properties/texture.rb +19 -0
  43. data/lib/rgltf/properties.rb +17 -0
  44. data/lib/rgltf/validation/accessors.rb +205 -0
  45. data/lib/rgltf/validation/animations.rb +184 -0
  46. data/lib/rgltf/validation/buffers.rb +95 -0
  47. data/lib/rgltf/validation/cameras.rb +76 -0
  48. data/lib/rgltf/validation/context.rb +133 -0
  49. data/lib/rgltf/validation/images_materials.rb +159 -0
  50. data/lib/rgltf/validation/meshes.rb +214 -0
  51. data/lib/rgltf/validation/root_asset_extensions.rb +134 -0
  52. data/lib/rgltf/validation/scenes_nodes.rb +141 -0
  53. data/lib/rgltf/validation/skins.rb +94 -0
  54. data/lib/rgltf/validator.rb +53 -0
  55. data/lib/rgltf/version.rb +5 -0
  56. data/lib/rgltf/writer/buffer_merger.rb +72 -0
  57. data/lib/rgltf/writer/default_omitter.rb +51 -0
  58. data/lib/rgltf/writer/extension_serializer.rb +64 -0
  59. data/lib/rgltf/writer.rb +95 -0
  60. data/lib/rgltf.rb +133 -0
  61. data/tasks/sample_assets.rake +115 -0
  62. metadata +104 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2e30d63eaf8903a7cfaabafc00da633aedd49748e4bd38eba9ef1a7e11be1f31
4
+ data.tar.gz: ef22f6e011a834d80afadef1b92a5bc0d4d4a00dbef1a9474b8419759974963d
5
+ SHA512:
6
+ metadata.gz: f51521619a9818267464db92bdac885e5413658993c8f541abaf11b098bd90197dda30feeac28d32a2f3bb14aa64d994f1bc04f8d8442f15f8e1c0655765adc3
7
+ data.tar.gz: 10cff90e96095d17856d2133b0dd13bc1ee971b367061beb489a0278840c4809ba241c66130b45deda20120fe862e7e865bc16ec7656034866bf1a70d89a1afa
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # Rgltf
2
+
3
+ Rgltf is a dependency-free Ruby library for loading, inspecting, building, and
4
+ writing glTF 2.0 JSON and GLB files. Its primary accessor API returns packed,
5
+ little-endian binary strings suitable for direct GPU upload, while Ruby arrays
6
+ remain available for inspection and CPU-side processing.
7
+
8
+ ## Features
9
+
10
+ - Load glTF 2.0 JSON and GLB from paths, IO objects, or strings.
11
+ - Resolve external files and data URIs with path-traversal protection.
12
+ - Read interleaved, sparse, normalized, and matrix accessors.
13
+ - Return GPU-ready accessor data without converting it to Ruby objects.
14
+ - Traverse scenes and calculate node world transforms.
15
+ - Build and write GLB, external-resource glTF, and embedded glTF.
16
+ - Validate documents and preserve unknown optional extensions.
17
+ - Parse four commonly used Khronos extensions out of the box.
18
+
19
+ Ruby 3.1 or newer is required.
20
+
21
+ ## Installation
22
+
23
+ Add Rgltf to your bundle:
24
+
25
+ ```sh
26
+ bundle add rgltf
27
+ ```
28
+
29
+ Alternatively, add it to your `Gemfile` and run `bundle install`:
30
+
31
+ ```ruby
32
+ gem "rgltf"
33
+ ```
34
+
35
+ For a standalone installation:
36
+
37
+ ```sh
38
+ gem install rgltf
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ Require the gem before using its public API:
44
+
45
+ ```ruby
46
+ require "rgltf"
47
+ ```
48
+
49
+ ### Loading documents
50
+
51
+ ```ruby
52
+ document = Rgltf.load("assets/model.glb")
53
+ document = Rgltf.load("assets/model.gltf")
54
+ document = Rgltf.load_glb(binary_string)
55
+ document = Rgltf.load_json(json_string, base_dir: "assets")
56
+ ```
57
+
58
+ External resources are resolved relative to the glTF file. Paths that escape
59
+ the base directory are rejected. Buffers are loaded lazily by default; pass
60
+ `lazy: false` to load them immediately.
61
+
62
+ Pass `validate: true` to validate while loading:
63
+
64
+ ```ruby
65
+ document = Rgltf.load("assets/model.glb", validate: true)
66
+ ```
67
+
68
+ All public failures inherit from `Rgltf::Error`. Invalid documents raise
69
+ `Rgltf::ValidationError`. Unknown required extensions raise
70
+ `Rgltf::UnsupportedExtensionError`; pass `strict_extensions: false` to record
71
+ a warning instead.
72
+
73
+ ### Reading GPU-ready accessor data
74
+
75
+ ```ruby
76
+ primitive = document.meshes.first.primitives.first
77
+ positions = primitive.attributes.fetch(:POSITION)
78
+
79
+ gpu_bytes = positions.packed # frozen ASCII-8BIT String
80
+ format = positions.vertex_format # for example, :float32x3
81
+ values = positions.to_a # [[x, y, z], ...]
82
+ indices = primitive.indices.packed_as_u32
83
+ ```
84
+
85
+ `packed` resolves interleaved buffer views, sparse accessors, and matrix column
86
+ padding. It preserves normalized integer components; `to_a` converts normalized
87
+ values to floats. Call `document.release_buffers!` after uploading the data when
88
+ the source buffers are no longer needed.
89
+
90
+ References in the source JSON are resolved to objects while loading:
91
+
92
+ ```ruby
93
+ document.default_scene.nodes.each(&:traverse)
94
+
95
+ document.each_mesh_instance do |node, mesh, world_matrix|
96
+ # world_matrix is a 16-element, column-major Ruby Array
97
+ end
98
+ ```
99
+
100
+ ### Building and writing documents
101
+
102
+ ```ruby
103
+ document = Rgltf::Builder.build do |builder|
104
+ builder.asset(generator: "my exporter")
105
+
106
+ positions = builder.accessor(:VEC3, :f32, positions_binary, min_max: true)
107
+ indices = builder.accessor(:SCALAR, :u16, indices_binary, target: :element_array)
108
+
109
+ texture = builder.texture(
110
+ image: File.binread("albedo.png"),
111
+ mime_type: "image/png",
112
+ sampler: {wrap_s: :repeat, mag_filter: :linear}
113
+ )
114
+ material = builder.material(
115
+ name: "body",
116
+ base_color_texture: texture,
117
+ metallic_factor: 0.0,
118
+ roughness_factor: 0.8
119
+ )
120
+
121
+ mesh = builder.mesh(name: "hull") do |mesh_builder|
122
+ mesh_builder.primitive(
123
+ attributes: {POSITION: positions},
124
+ indices: indices,
125
+ material: material
126
+ )
127
+ end
128
+
129
+ root = builder.node(name: "root", mesh: mesh)
130
+ builder.scene(nodes: [root], default: true)
131
+ end
132
+
133
+ document.write_glb("model.glb")
134
+ document.write_gltf("model.gltf")
135
+ document.write_gltf("embedded.gltf", embed: true)
136
+ ```
137
+
138
+ Builder buffer views are aligned to four bytes. Writer validation is enabled by
139
+ default and can be disabled with `validate: false`.
140
+
141
+ Use explicit buffer views for interleaved records and create sparse accessors
142
+ without constructing dense base buffers:
143
+
144
+ ```ruby
145
+ view = builder.buffer_view(vertex_records, byte_stride: 24, target: :array_buffer)
146
+ positions = builder.accessor_from_view(
147
+ :VEC3, :f32, buffer_view: view, count: vertex_count
148
+ )
149
+ normals = builder.accessor_from_view(
150
+ :VEC3, :f32, buffer_view: view, count: vertex_count, byte_offset: 12
151
+ )
152
+
153
+ weights = builder.sparse_accessor(
154
+ :SCALAR, :f32,
155
+ count: vertex_count,
156
+ indices: changed_vertex_indices,
157
+ values: changed_weights_binary,
158
+ index_component_type: :u16
159
+ )
160
+ ```
161
+
162
+ Texture-info handles retain per-use coordinates, transforms, and extras:
163
+
164
+ ```ruby
165
+ info = builder.texture_info(
166
+ texture,
167
+ tex_coord: 1,
168
+ extensions: {"KHR_texture_transform" => {"offset" => [0.5, 0.0]}}
169
+ )
170
+ material = builder.material(base_color_texture: info, double_sided: true)
171
+
172
+ builder.extensions_used("KHR_texture_transform")
173
+ builder.extension("VENDOR_scene", {"revision" => 2}, required: true)
174
+ builder.extras("exporter" => "example")
175
+ ```
176
+
177
+ Handles belong to the builder that created them. Passing a handle to another
178
+ builder raises `ArgumentError` before an invalid reference can be emitted.
179
+
180
+ ## Extensions
181
+
182
+ The default extension registry supports:
183
+
184
+ - `KHR_materials_unlit`
185
+ - `KHR_texture_transform`, including `TextureInfo#transform.uv_matrix`
186
+ - `KHR_lights_punctual`
187
+ - `KHR_materials_emissive_strength`
188
+
189
+ Register custom parsers on a separate registry when isolation is needed:
190
+
191
+ ```ruby
192
+ registry = Rgltf::ExtensionRegistry.new
193
+ registry.add("VENDOR_example", MyExtension, on: :material)
194
+ document = Rgltf.load(path, extensions: registry)
195
+ ```
196
+
197
+ Unknown optional extension payloads remain Hash objects, so documents can be
198
+ read and written without losing them. A handler's `serialize` method receives
199
+ the parsed object when writing. Handlers may also implement `decode_primitive`
200
+ to provide external Draco or meshopt decoding.
201
+
202
+ Handlers that omit defaults should declare the properties they handle, leaving
203
+ newer unknown properties untouched:
204
+
205
+ ```ruby
206
+ def self.serialize(object, doc:)
207
+ value = object.factor == 1.0 ? {} : {"factor" => object.factor}
208
+ serialize_properties(value, handled_keys: %w[factor])
209
+ end
210
+ ```
211
+
212
+ ## Examples
213
+
214
+ Runnable examples for building, inspecting, and converting assets are available
215
+ in [`examples/`](examples/README.md).
216
+
217
+ ## Development
218
+
219
+ Clone the repository and install its dependencies:
220
+
221
+ ```sh
222
+ git clone https://github.com/ydah/rgltf.git
223
+ cd rgltf
224
+ bundle install
225
+ ```
226
+
227
+ Run the unit and property suites:
228
+
229
+ ```sh
230
+ bundle exec rake
231
+ ```
232
+
233
+ Property checks use `prop_check` with shrinking and cover every accessor
234
+ component/type combination plus 100 generated Builder-to-GLB round trips. Replay
235
+ a reported case with:
236
+
237
+ ```sh
238
+ PROP_CHECK_SEED=<seed> bundle exec rspec spec/accessor_property_spec.rb
239
+ ```
240
+
241
+ The Khronos glTF-Sample-Assets corpus is pinned to
242
+ `2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf`:
243
+
244
+ ```sh
245
+ bundle exec rake sample_assets:fetch
246
+ GLTF_SAMPLE_ASSETS="$PWD/tmp/gltf-sample-assets" bundle exec rake sample_assets:verify
247
+ ```
248
+
249
+ Corpus verification covers 334 representations, including 310 fully materialized
250
+ representations and 24 containers that require an external Draco or meshopt
251
+ decoder. It also runs a 30-model feature manifest.
252
+
253
+ The repository additionally provides:
254
+
255
+ - `benchmark/sample_assets.rb` for repeatable load and accessor benchmarks.
256
+ - A performance workflow that rejects median regressions greater than 30%.
257
+ - A manual official glTF Validator workflow for GLB and glTF writer output.
258
+ - `spec/fixtures/public_api.json` as the machine-readable public API contract.
259
+
260
+ ## Contributing
261
+
262
+ Bug reports and pull requests are welcome on
263
+ [GitHub](https://github.com/ydah/rgltf). Please include a focused test for
264
+ behavior changes and ensure `bundle exec rake` passes before submitting a pull
265
+ request.
266
+
267
+ ## License
268
+
269
+ Rgltf is available as open source under the terms of the
270
+ [MIT License](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
9
+
10
+ Dir[File.join(__dir__, 'tasks', '*.rake')].each { |path| load path }
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/rgltf'
4
+
5
+ vertex_count = Integer(ENV.fetch('VERTEX_COUNT', '1000000'))
6
+ source = Array.new(vertex_count * 3, 1.0).pack('e*')
7
+ document = Rgltf.load_json(
8
+ {
9
+ 'asset' => { 'version' => '2.0' },
10
+ 'buffers' => [{ 'byteLength' => source.bytesize }],
11
+ 'bufferViews' => [{ 'buffer' => 0, 'byteLength' => source.bytesize }],
12
+ 'accessors' => [{ 'bufferView' => 0, 'componentType' => 5126, 'count' => vertex_count, 'type' => 'VEC3' }]
13
+ },
14
+ buffers: [source]
15
+ )
16
+ accessor = document.accessors.first
17
+
18
+ def measure(label)
19
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ yield
21
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
22
+ puts format('%-14s %0.6fs', label, elapsed)
23
+ end
24
+
25
+ measure('packed') { accessor.packed }
26
+ measure('to_a') { accessor.to_a }
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ base_path, candidate_path = ARGV
6
+ abort 'usage: ruby benchmark/compare.rb BASE.json CANDIDATE.json' unless candidate_path
7
+ threshold = Float(ENV.fetch('BENCHMARK_THRESHOLD', '0.30'))
8
+ base = JSON.parse(File.read(base_path)).fetch('models')
9
+ candidate = JSON.parse(File.read(candidate_path)).fetch('models')
10
+ regressions = []
11
+
12
+ candidate.each do |model, metrics|
13
+ metrics.each do |metric, values|
14
+ baseline = base.fetch(model).fetch(metric).fetch('median')
15
+ current = values.fetch('median')
16
+ change = baseline.zero? ? 0.0 : (current - baseline) / baseline
17
+ puts format('%-24s %-16s base=%0.6fs candidate=%0.6fs change=%+0.1f%%',
18
+ model, metric, baseline, current, change * 100)
19
+ regressions << [model, metric, change] if change > threshold
20
+ end
21
+ end
22
+
23
+ unless regressions.empty?
24
+ regressions.each do |model, metric, change|
25
+ warn format('regression: %s %s increased by %0.1f%%', model, metric, change * 100)
26
+ end
27
+ abort "performance regression exceeded #{(threshold * 100).round}%"
28
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ library = ENV.fetch('RGLTF_LIB_PATH', File.expand_path('../lib', __dir__))
6
+ $LOAD_PATH.unshift(library)
7
+ require 'rgltf'
8
+
9
+ root = ENV.fetch('GLTF_SAMPLE_ASSETS') do
10
+ abort 'Set GLTF_SAMPLE_ASSETS to a pinned glTF-Sample-Assets checkout'
11
+ end
12
+ iterations = Integer(ENV.fetch('BENCHMARK_ITERATIONS', '5'))
13
+ warmup = Integer(ENV.fetch('BENCHMARK_WARMUP', '1'))
14
+ abort 'BENCHMARK_ITERATIONS must be at least 3' if iterations < 3
15
+
16
+ def measure
17
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ value = yield
19
+ [Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at, value]
20
+ end
21
+
22
+ def median(values)
23
+ sorted = values.sort
24
+ middle = sorted.length / 2
25
+ return sorted.fetch(middle) if sorted.length.odd?
26
+
27
+ (sorted.fetch(middle - 1) + sorted.fetch(middle)) / 2.0
28
+ end
29
+
30
+ def representation(root, model)
31
+ path = File.join(root, 'Models', model, 'glTF-Binary', "#{model}.glb")
32
+ abort "Missing benchmark representation: #{path}" unless File.file?(path)
33
+
34
+ path
35
+ end
36
+
37
+ def run_once(path)
38
+ GC.start
39
+ load_seconds, document = measure { Rgltf.load(path, strict_extensions: false) }
40
+ packed_seconds, = measure { document.accessors.each(&:packed) }
41
+ { 'load_seconds' => load_seconds, 'packed_seconds' => packed_seconds }
42
+ end
43
+
44
+ models = %w[NodePerformanceTest BrainStem].to_h do |model|
45
+ path = representation(root, model)
46
+ warmup.times { run_once(path) }
47
+ samples = Array.new(iterations) { run_once(path) }
48
+ metrics = %w[load_seconds packed_seconds].to_h do |metric|
49
+ values = samples.map { |sample| sample.fetch(metric) }
50
+ [metric, { 'median' => median(values), 'samples' => values }]
51
+ end
52
+ [model, metrics]
53
+ end
54
+
55
+ puts JSON.pretty_generate(
56
+ 'schema' => 1,
57
+ 'ruby' => RUBY_DESCRIPTION,
58
+ 'warmup' => warmup,
59
+ 'iterations' => iterations,
60
+ 'models' => models
61
+ )
@@ -0,0 +1,23 @@
1
+ # Examples
2
+
3
+ Run these scripts from the repository with Bundler. When `rgltf` is installed,
4
+ the same commands work without `bundle exec`.
5
+
6
+ Build a self-contained triangle GLB:
7
+
8
+ ```sh
9
+ bundle exec ruby examples/build_triangle.rb tmp/triangle.glb
10
+ ```
11
+
12
+ Inspect meshes and GPU-ready accessor data:
13
+
14
+ ```sh
15
+ bundle exec ruby examples/inspect.rb tmp/triangle.glb
16
+ ```
17
+
18
+ Convert between GLB and glTF. Add `--embed` to place binary data in a data URI:
19
+
20
+ ```sh
21
+ bundle exec ruby examples/convert.rb model.glb model.gltf --embed
22
+ bundle exec ruby examples/convert.rb model.gltf model.glb
23
+ ```
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgltf'
4
+
5
+ output = File.expand_path(ARGV.fetch(0, 'triangle.glb'))
6
+ abort 'output must use the .glb extension' unless File.extname(output).downcase == '.glb'
7
+
8
+ positions = [
9
+ -0.5, -0.5, 0.0,
10
+ 0.5, -0.5, 0.0,
11
+ 0.0, 0.5, 0.0
12
+ ].pack('e*')
13
+ colors = [
14
+ 255, 64, 64, 255,
15
+ 64, 255, 64, 255,
16
+ 64, 64, 255, 255
17
+ ].pack('C*')
18
+ indices = [0, 1, 2].pack('S<*')
19
+
20
+ document = Rgltf::Builder.build do |builder|
21
+ builder.asset(generator: 'rgltf build_triangle example')
22
+ position_accessor = builder.accessor(:VEC3, :f32, positions, min_max: true)
23
+ color_accessor = builder.accessor(:VEC4, :u8, colors, normalized: true)
24
+ index_accessor = builder.accessor(:SCALAR, :u16, indices, target: :element_array)
25
+ material = builder.material(name: 'vertex colors', metallic_factor: 0.0, roughness_factor: 1.0)
26
+
27
+ mesh = builder.mesh(name: 'triangle') do |mesh_builder|
28
+ mesh_builder.primitive(
29
+ attributes: { POSITION: position_accessor, COLOR_0: color_accessor },
30
+ indices: index_accessor,
31
+ material: material
32
+ )
33
+ end
34
+
35
+ node = builder.node(name: 'triangle', mesh: mesh)
36
+ builder.scene(name: 'main', nodes: [node], default: true)
37
+ end
38
+
39
+ document.write_glb(output)
40
+ puts "wrote #{output}"
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgltf'
4
+
5
+ embed = ARGV.delete('--embed')
6
+ input, output = ARGV
7
+ abort 'usage: ruby examples/convert.rb INPUT OUTPUT [--embed]' unless input && output && ARGV.length == 2
8
+
9
+ input = File.expand_path(input)
10
+ output = File.expand_path(output)
11
+ abort 'input and output must be different files' if input == output
12
+
13
+ document = Rgltf.load(input, validate: true, strict_extensions: false)
14
+ case File.extname(output).downcase
15
+ when '.glb'
16
+ abort '--embed only applies to .gltf output' if embed
17
+ document.write_glb(output)
18
+ when '.gltf'
19
+ document.write_gltf(output, embed: !embed.nil?)
20
+ else
21
+ abort 'output must use the .glb or .gltf extension'
22
+ end
23
+
24
+ puts "wrote #{output}"
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgltf'
4
+
5
+ path = ARGV.shift || abort('usage: ruby examples/inspect.rb MODEL.glb|MODEL.gltf')
6
+ abort "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty?
7
+
8
+ document = Rgltf.load(path, validate: true, strict_extensions: false)
9
+ puts "asset version: #{document.asset.version}"
10
+ puts "generator: #{document.asset.generator || '(unspecified)'}"
11
+ puts "scenes: #{document.scenes.length}"
12
+ puts "meshes: #{document.meshes.length}"
13
+
14
+ document.meshes.each_with_index do |mesh, mesh_index|
15
+ puts "mesh #{mesh_index}: #{mesh.name || '(unnamed)'}"
16
+ mesh.primitives.each_with_index do |primitive, primitive_index|
17
+ puts " primitive #{primitive_index}: #{primitive.mode}"
18
+ primitive.attributes.each do |semantic, accessor|
19
+ puts " #{semantic}: count=#{accessor.count} format=#{accessor.vertex_format || 'n/a'} " \
20
+ "bytes=#{accessor.packed.bytesize}"
21
+ end
22
+ next unless primitive.indices
23
+
24
+ puts " indices: count=#{primitive.indices.count} " \
25
+ "u32_bytes=#{primitive.indices.packed_as_u32.bytesize}"
26
+ end
27
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rgltf
4
+ module AccessorReader
5
+ COMPONENT_TYPES = {
6
+ 5120 => { sym: :i8, size: 1, pack: 'c', min: -128, max: 127 },
7
+ 5121 => { sym: :u8, size: 1, pack: 'C', min: 0, max: 255 },
8
+ 5122 => { sym: :i16, size: 2, pack: 's<', min: -32_768, max: 32_767 },
9
+ 5123 => { sym: :u16, size: 2, pack: 'S<', min: 0, max: 65_535 },
10
+ 5125 => { sym: :u32, size: 4, pack: 'L<', min: 0, max: 4_294_967_295 },
11
+ 5126 => { sym: :f32, size: 4, pack: 'e' }
12
+ }.freeze
13
+ COMPONENTS_BY_SYMBOL = COMPONENT_TYPES.to_h { |number, info| [info[:sym], info.merge(number:)] }.freeze
14
+ ELEMENT_COUNTS = {
15
+ 'SCALAR' => 1, 'VEC2' => 2, 'VEC3' => 3, 'VEC4' => 4,
16
+ 'MAT2' => 4, 'MAT3' => 9, 'MAT4' => 16
17
+ }.freeze
18
+ MATRIX_DIMENSIONS = { 'MAT2' => 2, 'MAT3' => 3, 'MAT4' => 4 }.freeze
19
+
20
+ module_function
21
+
22
+ def packed(accessor)
23
+ info = COMPONENTS_BY_SYMBOL.fetch(accessor.component_type)
24
+ count = ELEMENT_COUNTS.fetch(accessor.type)
25
+ element_size = info[:size] * count
26
+ base = if accessor.buffer_view
27
+ extract(
28
+ accessor.buffer_view,
29
+ accessor.byte_offset,
30
+ accessor.count,
31
+ accessor.type,
32
+ info[:size],
33
+ element_size
34
+ )
35
+ else
36
+ "\0".b * (element_size * accessor.count)
37
+ end
38
+
39
+ return base.freeze unless accessor.sparse
40
+
41
+ apply_sparse(base, accessor, element_size).freeze
42
+ end
43
+
44
+ def unpack(accessor, bytes)
45
+ info = COMPONENTS_BY_SYMBOL.fetch(accessor.component_type)
46
+ values = bytes.unpack("#{info[:pack]}*")
47
+ values.map! { |value| normalize(value, accessor.component_type) } if accessor.normalized
48
+ count = ELEMENT_COUNTS.fetch(accessor.type)
49
+ count == 1 ? values : values.each_slice(count).to_a
50
+ end
51
+
52
+ def storage_element_size(type, component_size)
53
+ dimension = MATRIX_DIMENSIONS[type]
54
+ return ELEMENT_COUNTS.fetch(type) * component_size unless dimension && component_size < 4
55
+
56
+ aligned_column_size = align4(dimension * component_size)
57
+ aligned_column_size * dimension
58
+ end
59
+
60
+ def extract(view, accessor_offset, count, type, component_size, output_element_size)
61
+ storage_size = storage_element_size(type, component_size)
62
+ stride = view.byte_stride || storage_size
63
+ start = view.byte_offset + accessor_offset
64
+ required = count.zero? ? 0 : (stride * (count - 1)) + storage_size
65
+ if accessor_offset + required > view.byte_length
66
+ raise FormatError, "accessor data exceeds bufferView #{view.index}"
67
+ end
68
+
69
+ bytes = view.buffer.bytes
70
+ raise FormatError, "bufferView #{view.index} exceeds its buffer" if start + required > bytes.bytesize
71
+
72
+ if stride == output_element_size && storage_size == output_element_size
73
+ return bytes.byteslice(start, output_element_size * count).dup
74
+ end
75
+
76
+ output = String.new(capacity: output_element_size * count, encoding: Encoding::BINARY)
77
+ count.times do |element_index|
78
+ element_start = start + (element_index * stride)
79
+ append_element(output, bytes, element_start, type, component_size, storage_size)
80
+ end
81
+ output
82
+ end
83
+
84
+ def append_element(output, bytes, start, type, component_size, storage_size)
85
+ dimension = MATRIX_DIMENSIONS[type]
86
+ unless dimension && component_size < 4
87
+ output << bytes.byteslice(start, storage_size)
88
+ return
89
+ end
90
+
91
+ column_size = dimension * component_size
92
+ column_stride = align4(column_size)
93
+ dimension.times { |column| output << bytes.byteslice(start + (column * column_stride), column_size) }
94
+ end
95
+
96
+ def apply_sparse(base, accessor, element_size)
97
+ sparse = accessor.sparse
98
+ indices_info = COMPONENTS_BY_SYMBOL.fetch(sparse.indices.component_type)
99
+ index_bytes = extract(
100
+ sparse.indices.buffer_view,
101
+ sparse.indices.byte_offset,
102
+ sparse.count,
103
+ 'SCALAR',
104
+ indices_info[:size],
105
+ indices_info[:size]
106
+ )
107
+ indices = index_bytes.unpack("#{indices_info[:pack]}*")
108
+
109
+ component_size = COMPONENTS_BY_SYMBOL.fetch(accessor.component_type)[:size]
110
+ values = extract(
111
+ sparse.values.buffer_view,
112
+ sparse.values.byte_offset,
113
+ sparse.count,
114
+ accessor.type,
115
+ component_size,
116
+ element_size
117
+ )
118
+
119
+ output = base.dup
120
+ indices.each_with_index do |target, sparse_index|
121
+ raise FormatError, "sparse accessor index #{target} is out of range" if target >= accessor.count
122
+
123
+ output[target * element_size, element_size] = values.byteslice(sparse_index * element_size, element_size)
124
+ end
125
+ output
126
+ end
127
+
128
+ def normalize(value, type)
129
+ case type
130
+ when :i8 then [value / 127.0, -1.0].max
131
+ when :u8 then value / 255.0
132
+ when :i16 then [value / 32_767.0, -1.0].max
133
+ when :u16 then value / 65_535.0
134
+ else value
135
+ end
136
+ end
137
+
138
+ def align4(value)
139
+ (value + 3) & ~3
140
+ end
141
+ end
142
+ end