Skip to content

Commit ccd21c4

Browse files
einarfKurt Yoder
authored andcommitted
Binary cache for much faster loading (#63)
* Binary cache for vastly faster loading * README cache info * Binary cache for vastly faster loading * Test creating cache files * mock cache.open * Working py2 test * Stick to default encoding * Proper assert for dict compare * maxdiff off * Clean up cache logic * Repeat test for several files
1 parent fd41dac commit ccd21c4

File tree

11 files changed

+507
-15
lines changed

11 files changed

+507
-15
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
virtualenv venv
3535
. venv/bin/activate
3636
pip install -r requirements.txt
37-
pip install nose
37+
pip install nose mock
3838
sudo apt-get update
3939
sudo apt-get install freeglut3-dev
4040

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ dist
88
.idea
99
*.egg-info
1010
.venv
11-
11+
.venv2
12+
.venv3

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ A more complex example
3636
* `encoding` (Default: `utf-8`) of the obj and mtl file(s)
3737
* `create_materials` (Default: `False`) will create materials if mtl file is missing or obj file references non-existing materials
3838
* `parse` (Default: `True`) decides if parsing should start immediately.
39+
* `cache` (Default: `False`) writes the parsed geometry to a binary file for faster loading in the future
3940

4041
```python
4142
import pywavefront
@@ -56,6 +57,53 @@ for name, material in scene.materials.items():
5657
# ..
5758
```
5859

60+
## Binary Cache
61+
62+
When ``cache=True`` the interleaved vertex data is written
63+
as floats to a ``.bin`` file after the file is loaded. A json
64+
file is also generated describing the contents of the binary file.
65+
The binary file will be loaded the next time we attept to load
66+
the obj file reducing the loading time greatly.
67+
68+
Tests have shown loading time reduction by 10x to 30x.
69+
70+
Loading ``myfile.obj`` will generate the following files in the
71+
same directory.
72+
73+
```
74+
myfile.obj.bin
75+
myfile.obj.json
76+
```
77+
78+
Json file example:
79+
80+
```json
81+
{
82+
"created_at": "2018-07-16T14:28:43.451336",
83+
"version": "0.1",
84+
"materials": [
85+
"lost_empire.mtl"
86+
],
87+
"vertex_buffers": [
88+
{
89+
"material": "Stone",
90+
"vertex_format": "T2F_N3F_V3F",
91+
"byte_offset": 0,
92+
"byte_length": 5637888
93+
},
94+
{
95+
"material": "Grass",
96+
"vertex_format": "T2F_N3F_V3F",
97+
"byte_offset": 5637888,
98+
"byte_length": 6494208
99+
}
100+
]
101+
}
102+
```
103+
104+
These files will not be recreated until you delete them.
105+
The bin file is also compessed with gzip to greatly reduce size.
106+
59107
## Visualization
60108

61109
[Pyglet](http://www.pyglet.org/) is required to use the visualization module.

pywavefront/cache.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""
2+
Parser and metadata handler for cached binary versions of obj files
3+
"""
4+
import gzip
5+
import json
6+
import logging
7+
import os
8+
import struct
9+
from datetime import datetime
10+
11+
from pywavefront.material import Material, MaterialParser
12+
13+
logger = logging.getLogger("pywavefront")
14+
15+
16+
def cache_name(file_name):
17+
"""Generate the name of the binary cache file"""
18+
return "{}.bin".format(file_name)
19+
20+
21+
def meta_name(file_name):
22+
"""Generate the name of the meta file"""
23+
return "{}.json".format(file_name)
24+
25+
26+
class CacheLoader(object):
27+
material_parser_cls = MaterialParser
28+
29+
def __init__(self, file_name, wavefront, strict=False, create_materials=False, encoding='utf-8', parse=True, **kwargs):
30+
self.wavefront = wavefront
31+
self.file_name = file_name
32+
self.path = os.path.dirname(file_name)
33+
self.encoding = encoding
34+
self.strict = strict
35+
self.dir = os.path.dirname(file_name)
36+
self.meta = None
37+
38+
def parse(self):
39+
meta_exists = os.path.exists(meta_name(self.file_name))
40+
cache_exists = os.path.exists(cache_name(self.file_name))
41+
42+
if not meta_exists or not cache_exists:
43+
# If both files are missing, things are normal
44+
if not meta_exists and not cache_exists:
45+
logger.info("%s has no cache files", self.file_name)
46+
else:
47+
logger.warning("%s are missing a .bin or .json file. Cache loading will be disabled.", self.file_name)
48+
49+
return False
50+
51+
logger.info("%s loading cached version", self.file_name)
52+
53+
self.meta = Meta.from_file(meta_name(self.file_name))
54+
self._parse_mtllibs()
55+
self._load_vertex_buffers()
56+
57+
return True
58+
59+
def load_vertex_buffer(self, fd, material, length):
60+
"""
61+
Load vertex data from file. Can be overriden to reduce data copy
62+
63+
:param fd: file object
64+
:param material: The material these vertices belong to
65+
:param length: Byte length of the vertex data
66+
"""
67+
material.vertices = struct.unpack('{}f'.format(length // 4), fd.read(length))
68+
69+
def _load_vertex_buffers(self):
70+
"""Load each vertex buffer into each material"""
71+
fd = gzip.open(cache_name(self.file_name), 'rb')
72+
73+
for buff in self.meta.vertex_buffers:
74+
75+
mat = self.wavefront.materials.get(buff['material'])
76+
if not mat:
77+
mat = Material(name=buff['material'], is_default=True)
78+
self.wavefront.materials[mat.name] = mat
79+
80+
mat.vertex_format = buff['vertex_format']
81+
self.load_vertex_buffer(fd, mat, buff['byte_length'])
82+
83+
fd.close()
84+
85+
def _parse_mtllibs(self):
86+
"""Load mtl files"""
87+
for mtllib in self.meta.mtllibs:
88+
try:
89+
materials = self.material_parser_cls(
90+
os.path.join(self.path, mtllib),
91+
encoding=self.encoding,
92+
strict=self.strict).materials
93+
except IOError:
94+
raise IOError("Failed to load mtl file:".format(os.path.join(self.path, mtllib)))
95+
96+
for name, material in materials.items():
97+
self.wavefront.materials[name] = material
98+
99+
100+
class CacheWriter(object):
101+
102+
def __init__(self, file_name, wavefront):
103+
self.file_name = file_name
104+
self.wavefront = wavefront
105+
self.meta = Meta()
106+
107+
def write(self):
108+
logger.info("%s creating cache", self.file_name)
109+
110+
self.meta.mtllibs = self.wavefront.mtllibs
111+
112+
offset = 0
113+
fd = gzip.open(cache_name(self.file_name), 'wb')
114+
115+
for mat in self.wavefront.materials.values():
116+
117+
if len(mat.vertices) == 0:
118+
continue
119+
120+
self.meta.add_vertex_buffer(
121+
mat.name,
122+
mat.vertex_format,
123+
offset,
124+
len(mat.vertices) * 4,
125+
)
126+
offset += len(mat.vertices) * 4
127+
fd.write(struct.pack('{}f'.format(len(mat.vertices)), *mat.vertices))
128+
129+
fd.close()
130+
self.meta.write(meta_name(self.file_name))
131+
132+
133+
class Meta(object):
134+
"""
135+
Metadata for binary obj cache files
136+
"""
137+
format_version = "0.1"
138+
139+
def __init__(self, **kwargs):
140+
self._mtllibs = kwargs.get('mtllibs') or []
141+
self._vertex_buffers = kwargs.get('vertex_buffers') or []
142+
self._version = kwargs.get('version') or self.format_version
143+
self._created_at = kwargs.get('created_at') or datetime.now().isoformat()
144+
145+
def add_vertex_buffer(self, material, vertex_format, byte_offset, byte_length):
146+
"""Add a vertex buffer"""
147+
self._vertex_buffers.append({
148+
"material": material,
149+
"vertex_format": vertex_format,
150+
"byte_offset": byte_offset,
151+
"byte_length": byte_length,
152+
})
153+
154+
@classmethod
155+
def from_file(cls, path):
156+
with open(path, 'r') as fd:
157+
data = json.loads(fd.read())
158+
159+
return cls(**data)
160+
161+
def write(self, path):
162+
"""Save the metadata as json"""
163+
with open(path, 'w') as fd:
164+
fd.write(json.dumps(
165+
{
166+
"created_at": self._created_at,
167+
"version": self._version,
168+
"mtllibs": self._mtllibs,
169+
"vertex_buffers": self._vertex_buffers,
170+
},
171+
indent=2,
172+
))
173+
174+
@property
175+
def version(self):
176+
return self._version
177+
178+
@property
179+
def created_at(self):
180+
return self._created_at
181+
182+
@property
183+
def vertex_buffers(self):
184+
return self._vertex_buffers
185+
186+
@property
187+
def mtllibs(self):
188+
return self._mtllibs
189+
190+
@mtllibs.setter
191+
def mtllibs(self, value):
192+
self._mtllibs = value

pywavefront/material.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141

4242

4343
class Material(object):
44-
def __init__(self, name=None, is_default=False):
44+
def __init__(self, name, is_default=False):
4545
"""
4646
Create a new material
4747
:param name: Name of the material
@@ -62,6 +62,11 @@ def __init__(self, name=None, is_default=False):
6262

6363
self.gl_floats = None
6464

65+
@property
66+
def file(self):
67+
"""File with full path"""
68+
return os.path.join(self.path, self.name)
69+
6570
@property
6671
def has_normals(self):
6772
return "N3F" in self.vertex_format

pywavefront/mesh.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ def has_material(self, new_material):
5050
return False
5151

5252
def add_material(self, material):
53-
"""Add a material to the mesh, IFF it is not already present."""
53+
"""Add a material to the mesh, IF it's not already present."""
5454
if self.has_material(material):
5555
return
5656

0 commit comments

Comments
 (0)