1

I am trying to write out the voxelization of a model to a Wavefront Object File.

My method is simple, and runs in reasonable time. The problem is - it produces OBJ files that are ludicrous in size. I tried to load a 1 GB file into 3D Viewer on a very respectable machine with an SSD but in some cases the delay was several seconds when trying to move the camera, in others it refused to do anything at all and effectively softlocked.

What I've done so far:

  • I do not write out any faces which are internal to the model - that is, faces between two voxels which are both going to be written to the file. There's no point, as no-one can see them.
  • Because OBJ does not have a widely-supported binary format (as far as I know), I found that I could save some space by trimming trailing zeros from vertex positions in the file.

The obvious space-save I don't know how to do:

  • Not writing out duplicate vertices. In total, there are around 8x more vertices in the file than there should be. However, fixing this is extremely tricky because objects in Wavefront Object files do not use per-object, but global vertices. By writing out all 8 vertices each time, I always know which 8 vertices make up the next voxel. If I do not write out all 8, how do I keep track of which place in the global list I can find those 8 (if at all).

The harder, but potentially useful large space-save:

  • If I could work more abstractly, there may be a way to combine voxels into fewer objects, or combine faces that lie along the same plane into larger faces. IE, if two voxels both have their front face active, turn that into one larger rectangle twice as big.

Because it's required, here's some code that roughly shows what is happening. This isn't the code that's actually in use. I can't post that, and it relies on many user-defined types and has lots of code to handle edge cases or extra functionality so would be messy and length to put up here anyways.

The only thing that's important to the question is my method - going voxel-by-voxel, writing out all 8 vertices, and then writing out whichever of the 6 sides is not neighboring an active voxel. You'll just kind of have to trust me that it works, though it does produce large files.

My question is what method or approach I can use to reduce the size further. How can I, for example, not write out any duplicate vertices?

Assumptions:

  • Point is just an array of size 3, with getters like .x()
  • Vector3D is a 3D wrapper around std::vector, with a .at(x,y,z) method
  • Which voxels are active is arbitrary and does not follow a pattern, but is known before writeObj is called. Fetching if a voxel is active at any position is possible and fast.
//Left, right, bottom, top, front, rear
static const std::vector<std::vector<uint8_t>> quads = {
    {3, 0, 4, 7}, {1, 2, 6, 5}, {3, 2, 1, 0},
    {4, 5, 6, 7}, {0, 1, 5, 4}, {2, 3, 7, 6}};

void writeOBJ(
    std::string folder,
    const std::string& filename,
    const Vector3D<Voxel>& voxels,
    const Point<unsigned> gridDim,
    const Point<unsigned>& voxelCenterMinpoint,
    const float voxelWidth)
{
  unsigned numTris = 0;
  std::ofstream filestream;
  std::string filepath;
  std::string extension;
  ulong numVerticesWritten = 0;

  // Make sure the folder ends with a '/'
  if (folder.back() != '/')
    {
      folder.append("/");
    }

  filepath = folder + filename + ".obj";

  filestream.open(filepath, std::ios::out);

  // Remove the voxelization file if it already exists
  std::remove(filepath.c_str());

  Point<unsigned> voxelPos;

  for (voxelPos[0] = 0; voxelPos[0] < gridDim.x(); voxelPos[0]++)
    {
      for (voxelPos[1] = 0; voxelPos[1] < gridDim.y(); voxelPos[1]++)
        {
          for (voxelPos[2] = 0; voxelPos[2] < gridDim.z(); voxelPos[2]++)
            {
              if (voxels.at(voxelPos)) 
                {
                  writeVoxelToOBJ(
                      filestream, voxels, voxelPos, voxelCenterMinpoint, voxelWidth,
                      numVerticesWritten);
                }
            }
        }
    }

  filestream.close();
}

void writeVoxelToOBJ(
    std::ofstream& filestream,
    const Vector3D<Voxel>& voxels,
    const Point<unsigned>& voxelPos,
    const Point<unsigned>& voxelCenterMinpoint,
    const float voxelWidth,
    ulong& numVerticesWritten)
{
  std::vector<bool> neighborDrawable(6);
  std::vector<Vecutils::Point<float>> corners(8);
  unsigned numNeighborsDrawable = 0;

  // Determine which neighbors are active and what the 8 corners of the
  // voxel are
  writeVoxelAux(
      voxelPos, voxelCenterMinpoint, voxelWidth, neighborDrawable,
      numNeighborsDrawable, corners);

  // Normally, if all neighbors are active, there is no reason to write out this
  // voxel. (All its faces are internal) If inverted, the opposite is true.
  if (numNeighborsDrawable == 6)
    {
      return;
    }

  // Write out the vertices
  for (const Vecutils::Point<float>& corner : corners)
    {
      std::string x = std::to_string(corner.x());
      std::string y = std::to_string(corner.y());
      std::string z = std::to_string(corner.z());

      // Strip trailing zeros, they serve no prupose and bloat filesize
      x.erase(x.find_last_not_of('0') + 1, std::string::npos);
      y.erase(y.find_last_not_of('0') + 1, std::string::npos);
      z.erase(z.find_last_not_of('0') + 1, std::string::npos);

      filestream << "v " << x << " " << y << " " << z << "\n";
    }

  numVerticesWritten += 8;

  // The 6 sides of the voxel
  for (uint8_t i = 0; i < 6; i++)
    {
      // We only write them out if the neighbor in that direction
      // is inactive
      if (!neighborDrawable[i])
        {
          // The indices of the quad making up this face
          const std::vector<uint8_t>& quad0 = quads[i];

          ulong q0p0 = numVerticesWritten - 8 + quad0[0] + 1;
          ulong q0p1 = numVerticesWritten - 8 + quad0[1] + 1;
          ulong q0p2 = numVerticesWritten - 8 + quad0[2] + 1;
          ulong q0p3 = numVerticesWritten - 8 + quad0[3] + 1;

          // Wavefront object files are 1-indexed with regards to vertices
          filestream << "f " << std::to_string(q0p0) << " "
                     << std::to_string(q0p1) << " " << std::to_string(q0p2)
                     << " " << std::to_string(q0p3) << "\n";
        }
    }
}

void writeVoxelAux(
    const Point<unsigned>& voxelPos,
    const Point<unsigned>& voxelCenterMinpoint,
    const float voxelWidth,
    std::vector<bool>& neighborsDrawable,
    unsigned& numNeighborsDrawable,
    std::vector<Point<float>>& corners)
{
  // Which of the 6 immediate neighbors of the voxel are active?
  for (ulong i = 0; i < 6; i++)
    {
      neighborsDrawable[i] = isNeighborDrawable(voxelPos.cast<int>() + off[i]);

      numNeighborsDrawable += neighborsDrawable[i];
    }

  // Coordinates of the center of the voxel
  Vecutils::Point<float> center =
      voxelCenterMinpoint + (voxelPos.cast<float>() * voxelWidth);

  // From this center, we can get the 8 corners of the triangle
  for (ushort i = 0; i < 8; i++)
    {
      corners[i] = center + (crnoff[i] * (voxelWidth / 2));
    }
}

Addendum:

While I ultimately ended up doing something like what @Tau suggested, there was one key difference - the comparison operator.

For points represented by 3 floats, < and == is not sufficient. Even using tolerances on both, it does not work consistently and had discrepancies between my debug and release mode.

I have a new method I will post here when I can, though even it is not 100% foolproof.

Tyler Shellberg
  • 1,086
  • 11
  • 28
  • _Not writing out duplicate vertices._ This is what I did for OpenGL meshes which a rendered with index. So, I process all vertices and store - the values in a `std::vector`, their indices into a `std::set`. For the `std::set`, I use a custom predicate which compares the indexed vertices instead of the indices themselves. With this, I'm able to recognize duplicates quite efficiently and to re-use the original whenever possible. (I also tried `std::unordered_set`. It was marginally faster but finally I sticked to `std::set`.) – Scheff's Cat Mar 26 '20 at 05:02
  • I'm afraid this question in its current form is too broad to be answered sufficiently on StackOverflow. Naive voxelization is extremely simple, but horribly inefficient. The entirety of research on voxel graphics is considered with reducing those inefficiencies. You may want to do some research on the current state of the art in visualizing voxel data sets and come back with a more focussed question. – ComicSansMS Mar 26 '20 at 10:13
  • @ComicSansMS: indeed so for the second question, but the first one seems quite concrete and answerable. – Tau Mar 26 '20 at 11:18

2 Answers2

1

If you define a custom comparator like this:

struct PointCompare
{
  bool operator() (const Point<float>& lhs, const Point<float>& rhs) const
  {
    if (lhs.x() < rhs.x()) // x position is most significant (arbitrary)
      return true;
    else if (lhs.x() == rhs.x()) {
      if (lhs.y() < rhs.y())
        return true;
      else if (lhs.y() == lhs.y())
        return lhs.z() < rhs.z();
    }
  }
};

you could then make a map from Points to their index in a vector, and whenever you use a vertex in a face, check if it already exists:

std::vector<Point> vertices;
std::map<Point, unsigned, PointCompare> indices;

unsigned getVertexIndex(Point<float>& p) {
  auto it = indices.find(p);
  if (it != indices.end()) // known vertex
    return it->second;
  else { // new vertex, store in list
    unsigned pos = vertices.size();
    vertices.push_back(p);
    indices[p] = pos;
    return pos;
  }
}

Compute all faces using this, then write the vertices to file, then the faces.

Combining voxel faces optimally is indeed somewhat more complicated than this, but if you want to have a go at it, check this out.

Alternatively, if you are handling only a few meshes overall, you may want to save yourself the hassle optimizing your code, and use the free MeshLab, which can remove duplicate vertices, merge faces and export to a variety of (more efficient) formats with just a few clicks.

Btw: Storing your voxels in a list is only efficient if they are really sparse; using a bool[][][] instead will be more efficient in most cases and really simplyfy your algorithms (e. g. for finding neighbors).

Tau
  • 496
  • 4
  • 22
  • Thanks for the reply. If I already have overloaded ```operator<(Const Point& rhs)``` and ```operator==(Const Point& rhs)``` for ```Point```, is there a way to borrow those, or do I specifically need a comparison operator that takes two points? If so, could I make it a static method of ```Point``` instead of wrapping it in a struct, or is that necessary? I did not realize std::map could take a comparison operator! This seems like a very simple and clean solution - which I will try out shortly. – Tyler Shellberg Mar 26 '20 at 16:32
  • Also, in this case, would it be appropriate to use an "approximately equals" method instead of == if using floats? – Tyler Shellberg Mar 26 '20 at 16:37
  • Yeah, if you already implemented <, it should totally work without needing to specify that operator! You would only need approximate equals if you introduce any rounding errors, but at first glance, the above code seems fine. – Tau Mar 26 '20 at 16:49
  • Just in case, I attempted this solution with your method for point comparison. However, it does not seem to work. I am seeing duplicates in the list of vertices, which is messing it all up. I added a tolerance to the ```==```, but may also try to the lesser operations, and see if that fixes it. EDIT: Adding tolerance to < fixed it. – Tyler Shellberg Mar 26 '20 at 18:26
  • The results are also wrong in a release build, but works great in debug. Hmm. – Tyler Shellberg Mar 26 '20 at 18:46
  • Here is what some output looks like for me for the ```indices``` map. https://pastebin.com/SzCRaNG4 I am testing each time a vertex is added if the point 3.159, 0.203, 1.961 is present in the map. In debug, 3 vertices are inserted before it and report that it is not present. Then, it gets inserted, and from then on it is in the map. In release, however, it only shows up immediately after being inserted - then it vanishes? Any idea why? – Tyler Shellberg Mar 26 '20 at 19:48
  • Will take a look at it tomorrow – Tau Mar 26 '20 at 22:05
  • 1
    I ended up fixing it. I just needed to change the comparison operator. Will post specifics tomorrow. – Tyler Shellberg Mar 26 '20 at 22:16
  • Your comparison operator has code paths which end without `return`. (Actually, the compiler should've told you.) Please, debug a case where `lhs.x()` is greater than `rhs.x()` to see what I mean. – Scheff's Cat Mar 27 '20 at 06:02
0

The obvious space-save I don't know how to do:

  • Not writing out duplicate vertices. In total, there are around 8x more vertices in the file than there should be. However, fixing this is extremely tricky because objects in Wavefront Object files do not use per-object, but global vertices. By writing out all 8 vertices each time, I always know which 8 vertices make up the next voxel. If I do not write out all 8, how do I keep track of which place in the global list I can find those 8 (if at all).

This is something I once did in the past to prepare meshes (out of loaded geometry) for OpenGL buffers. For this, I intended to remove duplicates from vertices as an index buffer was already planned.

This is what I did: Insert all vertices in a std::set which will eliminate duplicates.

To lower the memory consumption, I used a std::set with index type (e.g. size_t or unsigned) with a custom predicate which does the comparison on the indexed coordinates.

The custom less predicate:

// functor for less predicate comparing indexed values
template <typename VALUE, typename INDEX>
struct LessValueT {
  VALUE *values;
  LessValueT(std::vector<VALUE> &values): values(values.data()) { }
  bool operator()(INDEX i1, INDEX i2) const { return values[i1] < values[i2]; }
};

and the std::set with this predicate:

// an index table (sorting indices by indexed values)
template <typename VALUE, typename INDEX>
using LookUpTableT = std::set<INDEX, LessValueT<VALUE, INDEX>>;

To use the above with coordinates (or normals) which are stored e.g. as

template <typename VALUE>
struct Vec3T { VALUE x, y, z; };

it is necessary to overload the less operator accordingly which I did in the most naïve way for this sample:

template <typename VALUE>
bool operator<(const Vec3T<VALUE> &vec1, const Vec3T<VALUE> &vec2)
{
  return vec1.x < vec2.x ? true : vec1.x > vec2.x ? false
    : vec1.y < vec2.y ? true : vec1.y > vec2.y ? false
    : vec1.z < vec2.z;
}

Thereby, it is not necessary to think about sense or non-sense of the order which results from this predicate. It just must fit into the requirements of the std::set to distinguish and sort vector values with differing components.

To demonstrate this, I use a tetrix sponge:

Tetrix Sponge (on mathworld.wolfram.com)

It is easy to build with varying number of triangles (depending on sub-division levels) and resembles IMHO quite good the assumptions I did about OPs data:

  • a significant number of shared vertices
  • a small number of different normals.

The complete sample code testCollectVtcs.cc:

#include <cassert>
#include <cmath>
#include <chrono>
#include <fstream>
#include <functional>
#include <iostream>
#include <numeric>
#include <set>
#include <string>
#include <vector>

namespace Compress {

// functor for less predicate comparing indexed values
template <typename VALUE, typename INDEX>
struct LessValueT {
  VALUE *values;
  LessValueT(std::vector<VALUE> &values): values(values.data()) { }
  bool operator()(INDEX i1, INDEX i2) const { return values[i1] < values[i2]; }
};

// an index table (sorting indices by indexed values)
template <typename VALUE, typename INDEX>
using LookUpTableT = std::set<INDEX, LessValueT<VALUE, INDEX>>;

} // namespace Compress

// the compress function - modifies the values vector
template <typename VALUE, typename INDEX = size_t>
std::vector<INDEX> compress(std::vector<VALUE> &values)
{
  typedef Compress::LessValueT<VALUE, INDEX> LessValue;
  typedef Compress::LookUpTableT<VALUE, INDEX> LookUpTable;
  // collect indices and remove duplicate values
  std::vector<INDEX> idcs; idcs.reserve(values.size());
  LookUpTable lookUp((LessValue(values)));
  INDEX iIn = 0, nOut = 0;
  for (const INDEX n = values.size(); iIn < n; ++iIn) {
    values[nOut] = values[iIn];
    std::pair<LookUpTable::iterator, bool> ret = lookUp.insert(nOut);
    if (ret.second) { // new index added?
      ++nOut; // remark value as stored
    }
    idcs.push_back(*ret.first); // store index
  }
  // discard all obsolete values
  values.resize(nOut);
  // done
  return idcs;
}

// instrumentation to take times

typedef std::chrono::high_resolution_clock Clock;
typedef std::chrono::microseconds USecs;
typedef decltype(std::chrono::duration_cast<USecs>(Clock::now() - Clock::now())) Time;

Time duration(const Clock::time_point &t0)
{
  return std::chrono::duration_cast<USecs>(Clock::now() - t0);
}

Time stopWatch(std::function<void()> func)
{
  const Clock::time_point t0 = Clock::now();
  func();
  return duration(t0);
}

// a minimal linear algebra tool set

template <typename VALUE>
struct Vec3T { VALUE x, y, z; };

template <typename VALUE>
Vec3T<VALUE> operator*(const Vec3T<VALUE> &vec, VALUE s) { return { vec.x * s, vec.y * s, vec.z * s }; }

template <typename VALUE>
Vec3T<VALUE> operator*(VALUE s, const Vec3T<VALUE> &vec) { return { s * vec.x, s * vec.y, s * vec.z }; }

template <typename VALUE>
Vec3T<VALUE> operator+(const Vec3T<VALUE> &vec1, const Vec3T<VALUE> &vec2)
{
  return { vec1.x + vec2.x, vec1.y + vec2.y, vec1.z + vec2.z };
}

template <typename VALUE>
Vec3T<VALUE> operator-(const Vec3T<VALUE> &vec1, const Vec3T<VALUE> &vec2)
{
  return { vec1.x - vec2.x, vec1.y - vec2.y, vec1.z - vec2.z };
}

template <typename VALUE>
VALUE length(const Vec3T<VALUE> &vec)
{
  return std::sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z);
}

template <typename VALUE>
VALUE dot(const Vec3T<VALUE> &vec1, const Vec3T<VALUE> &vec2)
{
  return vec1.x * vec2.x + vec1.y * vec2.y + vec1.z * vec2.z;
}

template <typename VALUE>
Vec3T<VALUE> cross(const Vec3T<VALUE> &vec1, const Vec3T<VALUE> &vec2)
{
  return {
    vec1.y * vec2.z - vec1.z * vec2.y,
    vec1.z * vec2.x - vec1.x * vec2.z,
    vec1.x * vec2.y - vec1.y * vec2.x
  };
}

template <typename VALUE>
Vec3T<VALUE> normalize(const Vec3T<VALUE> &vec) { return (VALUE)1 / length(vec) * vec; }

// build sample - a tetraeder sponge

template <typename VALUE>
using StoreTriFuncT = std::function<void(const Vec3T<VALUE>&, const Vec3T<VALUE>&, const Vec3T<VALUE>&)>;

namespace TetraSponge {

template <typename VALUE>
void makeTetrix(
  const Vec3T<VALUE> &p0, const Vec3T<VALUE> &p1,
  const Vec3T<VALUE> &p2, const Vec3T<VALUE> &p3,
  StoreTriFuncT<VALUE> &storeTri)
{
  storeTri(p0, p1, p2);
  storeTri(p0, p2, p3);
  storeTri(p0, p3, p1);
  storeTri(p1, p3, p2);
}

template <typename VALUE>
void subDivide(
  unsigned depth,
  const Vec3T<VALUE> &p0, const Vec3T<VALUE> &p1,
  const Vec3T<VALUE> &p2, const Vec3T<VALUE> &p3,
  StoreTriFuncT<VALUE> &storeTri)
{
  if (!depth) { // build the 4 triangles
    makeTetrix(p0, p1, p2, p3, storeTri);
  } else {
    --depth;
    auto middle = [](const Vec3T<VALUE> &p0, const Vec3T<VALUE> &p1)
    {
      return 0.5f * p0 + 0.5f * p1;
    };
    const Vec3T<VALUE> p01 = middle(p0, p1);
    const Vec3T<VALUE> p02 = middle(p0, p2);
    const Vec3T<VALUE> p03 = middle(p0, p3);
    const Vec3T<VALUE> p12 = middle(p1, p2);
    const Vec3T<VALUE> p13 = middle(p1, p3);
    const Vec3T<VALUE> p23 = middle(p2, p3);
    subDivide(depth, p0, p01, p02, p03, storeTri);
    subDivide(depth, p01, p1, p12, p13, storeTri);
    subDivide(depth, p02, p12, p2, p23, storeTri);
    subDivide(depth, p03, p13, p23, p3, storeTri);
  }
}

} // namespace TetraSponge

template <typename VALUE>
void makeTetraSponge(
  unsigned depth, // recursion depth (values 0 ... 9 recommended)
  StoreTriFuncT<VALUE> &storeTri)
{
  TetraSponge::subDivide(depth,
    { -1, -1, -1 },
    { +1, +1, -1 },
    { +1, -1, +1 },
    { -1, +1, +1 },
    storeTri);
}

// minimal obj file writer

template <typename VALUE, typename INDEX>
void writeObjFile(
  std::ostream &out,
  const std::vector<Vec3T<VALUE>> &coords, const std::vector<INDEX> &idcsCoords,
  const std::vector<Vec3T<VALUE>> &normals, const std::vector<INDEX> &idcsNormals)
{
  assert(idcsCoords.size() == idcsNormals.size());
  out
    << "# Wavefront OBJ file\n"
    << "\n"
    << "# " << coords.size() << " coordinates\n";
  for (const Vec3 &coord : coords) {
    out << "v " << coord.x << " " << coord.y << " " << coord.z << '\n';
  }
  out
    << "# " << normals.size() << " normals\n";
  for (const Vec3 &normal : normals) {
    out << "vn " << normal.x << " " << normal.y << " " << normal.z << '\n';
  }
  out
    << "\n"
    << "g faces\n"
    << "# " << idcsCoords.size() / 3 << " triangles\n";
  for (size_t i = 0, n = idcsCoords.size(); i < n; i += 3) {
    out << "f "
      << idcsCoords[i + 0] + 1 << "//" << idcsNormals[i + 0] + 1 << ' '
      << idcsCoords[i + 1] + 1 << "//" << idcsNormals[i + 1] + 1 << ' '
      << idcsCoords[i + 2] + 1 << "//" << idcsNormals[i + 2] + 1 << '\n';
  }
}

template <typename VALUE, typename INDEX = size_t>
void writeObjFile(
  std::ostream &out,
  const std::vector<Vec3T<VALUE>> &coords, const std::vector<Vec3T<VALUE>> &normals)
{
  assert(coords.size() == normals.size());
  std::vector<INDEX> idcsCoords(coords.size());
  std::iota(idcsCoords.begin(), idcsCoords.end(), 0);
  std::vector<INDEX> idcsNormals(normals.size());
  std::iota(idcsNormals.begin(), idcsNormals.end(), 0);
  writeObjFile(out, coords, idcsCoords, normals, idcsNormals);
}

// main program (experiment)

template <typename VALUE>
bool operator<(const Vec3T<VALUE> &vec1, const Vec3T<VALUE> &vec2)
{
  return vec1.x < vec2.x ? true : vec1.x > vec2.x ? false
    : vec1.y < vec2.y ? true : vec1.y > vec2.y ? false
    : vec1.z < vec2.z;
}

using Vec3 = Vec3T<float>;
using StoreTriFunc = StoreTriFuncT<float>;

int main(int argc, char **argv)
{
  // read command line options
  if (argc <= 2) {
    std::cerr
      << "Usage:\n"
      << "> testCollectVtcs DEPTH FILE\n";
    return 1;
  }
  const unsigned depth = std::stoi(argv[1]);
  const std::string file = argv[2];
  std::cout << "Build sample...\n";
  std::vector<Vec3> coords, normals;
  { const Time t = stopWatch([&]() {
      StoreTriFunc storeTri = [&](const Vec3 &p0, const Vec3 &p1, const Vec3 &p2) {
        coords.push_back(p0); coords.push_back(p1); coords.push_back(p2);
        const Vec3 n = normalize(cross(p0 - p2, p1 - p2));
        normals.push_back(n); normals.push_back(n); normals.push_back(n);
      };
      makeTetraSponge(depth, storeTri);
    });
    std::cout << "Done after " << t.count() << " us.\n";
  }
  std::cout << "coords: " << coords.size() << ", normals: " << normals.size() << '\n';
  const std::string fileUncompr = file + ".uncompressed.obj";
  std::cout << "Write uncompressed OBJ file '" << fileUncompr << "'...\n";
  { const Time t = stopWatch([&]() {
      std::ofstream fOut(fileUncompr.c_str(), std::ios::binary);
      /* std::ios::binary -> force Unix line-endings on Windows
       * to win some extra bytes
       */
      writeObjFile(fOut, coords, normals);
      fOut.close();
      if (!fOut.good()) {
        std::cerr << "Writing of '" << fileUncompr << "' failed!\n";
        throw std::ios::failure("Failed to complete writing of file!");
      }
    });
    std::cout << "Done after " << t.count() << " us.\n";
  }
  std::cout << "Compress coordinates and normals...\n";
  std::vector<size_t> idcsCoords, idcsNormals;
  { const Time t = stopWatch([&]() {
      idcsCoords = compress(coords);
      idcsNormals = compress(normals);
    });
    std::cout << "Done after " << t.count() << " us.\n";
  }
  std::cout
    << "coords: " << coords.size() << ", normals: " << normals.size() << '\n'
    << "coord idcs: " << idcsCoords.size() << ", normals: " << normals.size() << '\n';
  const std::string fileCompr = file + ".compressed.obj";
  std::cout << "Write compressed OBJ file'" << fileCompr << "'...\n";
  { const Time t = stopWatch([&]() {
      std::ofstream fOut(fileCompr.c_str(), std::ios::binary);
      /* std::ios::binary -> force Unix line-endings on Windows
       * to win some extra bytes
       */
      writeObjFile(fOut, coords, idcsCoords, normals, idcsNormals);
      fOut.close();
      if (!fOut.good()) {
        std::cerr << "Writing of '" << fileCompr << "' failed!\n";
        throw std::ios::failure("Failed to complete writing of file!");
      }
    });
    std::cout << "Done after " << t.count() << " us.\n";
  }
  std::cout << "Done.\n";
}

A first check:

> testCollectVtcs
Usage:
> testCollectVtcs DEPTH FILE

> testCollectVtcs 1 test1
Build sample...
Done after 34 us.
coords: 48, normals: 48
Write uncompressed OBJ file 'test1.uncompressed.obj'...
Done after 1432 us.
Compress coordinates and normals...
Done after 12 us.
coords: 10, normals: 4
coord idcs: 48, normals: 4
Write compressed OBJ file'test1.compressed.obj'...
Done after 1033 us.
Done.

This produced two files:

$ ls test1.*.obj
-rw-r--r-- 1 Scheff 1049089  553 Mar 26 11:46 test1.compressed.obj
-rw-r--r-- 1 Scheff 1049089 2214 Mar 26 11:46 test1.uncompressed.obj

$
$ cat test1.uncompressed.obj
# Wavefront OBJ file

# 48 coordinates
v -1 -1 -1
v 0 0 -1
v 0 -1 0
v -1 -1 -1
v 0 -1 0
v -1 0 0
v -1 -1 -1
v -1 0 0
v 0 0 -1
v 0 0 -1
v -1 0 0
v 0 -1 0
v 0 0 -1
v 1 1 -1
v 1 0 0
v 0 0 -1
v 1 0 0
v 0 1 0
v 0 0 -1
v 0 1 0
v 1 1 -1
v 1 1 -1
v 0 1 0
v 1 0 0
v 0 -1 0
v 1 0 0
v 1 -1 1
v 0 -1 0
v 1 -1 1
v 0 0 1
v 0 -1 0
v 0 0 1
v 1 0 0
v 1 0 0
v 0 0 1
v 1 -1 1
v -1 0 0
v 0 1 0
v 0 0 1
v -1 0 0
v 0 0 1
v -1 1 1
v -1 0 0
v -1 1 1
v 0 1 0
v 0 1 0
v -1 1 1
v 0 0 1
# 48 normals
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn 0.57735 -0.57735 -0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn -0.57735 0.57735 -0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735
vn 0.57735 0.57735 0.57735

g faces
# 16 triangles
f 1//1 2//2 3//3
f 4//4 5//5 6//6
f 7//7 8//8 9//9
f 10//10 11//11 12//12
f 13//13 14//14 15//15
f 16//16 17//17 18//18
f 19//19 20//20 21//21
f 22//22 23//23 24//24
f 25//25 26//26 27//27
f 28//28 29//29 30//30
f 31//31 32//32 33//33
f 34//34 35//35 36//36
f 37//37 38//38 39//39
f 40//40 41//41 42//42
f 43//43 44//44 45//45
f 46//46 47//47 48//48

$
$ cat test1.compressed.obj
# Wavefront OBJ file

# 10 coordinates
v -1 -1 -1
v 0 0 -1
v 0 -1 0
v -1 0 0
v 1 1 -1
v 1 0 0
v 0 1 0
v 1 -1 1
v 0 0 1
v -1 1 1
# 4 normals
vn 0.57735 -0.57735 -0.57735
vn -0.57735 -0.57735 0.57735
vn -0.57735 0.57735 -0.57735
vn 0.57735 0.57735 0.57735

g faces
# 16 triangles
f 1//1 2//1 3//1
f 1//2 3//2 4//2
f 1//3 4//3 2//3
f 2//4 4//4 3//4
f 2//1 5//1 6//1
f 2//2 6//2 7//2
f 2//3 7//3 5//3
f 5//4 7//4 6//4
f 3//1 6//1 8//1
f 3//2 8//2 9//2
f 3//3 9//3 6//3
f 6//4 9//4 8//4
f 4//1 7//1 9//1
f 4//2 9//2 10//2
f 4//3 10//3 7//3
f 7//4 10//4 9//4

$

So, this what came out

  • 48 coordinates vs. 10 coordinates
  • 48 normals vs. 4 normals.

And this is, how this looks:

snapshot of test1.uncompressed.obj in RF::SGEdit²

(I couldn't see any visual difference to test1.compressed.obj.)

Concerning the stop-watched times, I wouldn't trust too much them. For this, the sample was much too small.

So, another test with more geometry (much more):

> testCollectVtcs 8 test8
Build sample...
Done after 40298 us.
coords: 786432, normals: 786432
Write uncompressed OBJ file 'test8.uncompressed.obj'...
Done after 6200571 us.
Compress coordinates and normals...
Done after 115817 us.
coords: 131074, normals: 4
coord idcs: 786432, normals: 4
Write compressed OBJ file'test8.compressed.obj'...
Done after 1513216 us.
Done.

>

The two files:

$ ls -l test8.*.obj
-rw-r--r-- 1 ds32737 1049089 11540967 Mar 26 11:56 test8.compressed.obj
-rw-r--r-- 1 ds32737 1049089 57424470 Mar 26 11:56 test8.uncompressed.obj

$

To summarize it:

  • 11 MBytes vs. 56 MBytes.
  • compressing and writing: 0.12 s + 1.51 s = 1.63 s
  • vs. writing the uncompressed: 6.2 s

snapshot of test8.compressed.obj in RF::SGEdit²

Scheff's Cat
  • 19,528
  • 6
  • 28
  • 56