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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/Rakefile +10 -0
- data/benchmark/accessors.rb +26 -0
- data/benchmark/compare.rb +28 -0
- data/benchmark/sample_assets.rb +61 -0
- data/examples/README.md +23 -0
- data/examples/build_triangle.rb +40 -0
- data/examples/convert.rb +24 -0
- data/examples/inspect.rb +27 -0
- data/lib/rgltf/accessor_reader.rb +142 -0
- data/lib/rgltf/buffer_resolver.rb +95 -0
- data/lib/rgltf/builder/accessor_data.rb +40 -0
- data/lib/rgltf/builder/accessors.rb +130 -0
- data/lib/rgltf/builder/component_builders.rb +89 -0
- data/lib/rgltf/builder/document_properties.rb +32 -0
- data/lib/rgltf/builder/materials.rb +132 -0
- data/lib/rgltf/builder.rb +176 -0
- data/lib/rgltf/document.rb +159 -0
- data/lib/rgltf/errors.rb +10 -0
- data/lib/rgltf/extension.rb +79 -0
- data/lib/rgltf/extensions/khr_lights_punctual.rb +101 -0
- data/lib/rgltf/extensions/khr_materials_emissive_strength.rb +29 -0
- data/lib/rgltf/extensions/khr_materials_unlit.rb +19 -0
- data/lib/rgltf/extensions/khr_texture_transform.rb +42 -0
- data/lib/rgltf/glb.rb +71 -0
- data/lib/rgltf/properties/accessor.rb +142 -0
- data/lib/rgltf/properties/animation.rb +96 -0
- data/lib/rgltf/properties/asset.rb +15 -0
- data/lib/rgltf/properties/base.rb +45 -0
- data/lib/rgltf/properties/buffer.rb +50 -0
- data/lib/rgltf/properties/buffer_view.rb +18 -0
- data/lib/rgltf/properties/camera.rb +50 -0
- data/lib/rgltf/properties/image.rb +48 -0
- data/lib/rgltf/properties/material.rb +95 -0
- data/lib/rgltf/properties/mesh.rb +67 -0
- data/lib/rgltf/properties/node.rb +135 -0
- data/lib/rgltf/properties/sampler.rb +35 -0
- data/lib/rgltf/properties/scene.rb +14 -0
- data/lib/rgltf/properties/skin.rb +25 -0
- data/lib/rgltf/properties/texture.rb +19 -0
- data/lib/rgltf/properties.rb +17 -0
- data/lib/rgltf/validation/accessors.rb +205 -0
- data/lib/rgltf/validation/animations.rb +184 -0
- data/lib/rgltf/validation/buffers.rb +95 -0
- data/lib/rgltf/validation/cameras.rb +76 -0
- data/lib/rgltf/validation/context.rb +133 -0
- data/lib/rgltf/validation/images_materials.rb +159 -0
- data/lib/rgltf/validation/meshes.rb +214 -0
- data/lib/rgltf/validation/root_asset_extensions.rb +134 -0
- data/lib/rgltf/validation/scenes_nodes.rb +141 -0
- data/lib/rgltf/validation/skins.rb +94 -0
- data/lib/rgltf/validator.rb +53 -0
- data/lib/rgltf/version.rb +5 -0
- data/lib/rgltf/writer/buffer_merger.rb +72 -0
- data/lib/rgltf/writer/default_omitter.rb +51 -0
- data/lib/rgltf/writer/extension_serializer.rb +64 -0
- data/lib/rgltf/writer.rb +95 -0
- data/lib/rgltf.rb +133 -0
- data/tasks/sample_assets.rake +115 -0
- 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,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
|
+
)
|
data/examples/README.md
ADDED
|
@@ -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}"
|
data/examples/convert.rb
ADDED
|
@@ -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}"
|
data/examples/inspect.rb
ADDED
|
@@ -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
|