Skip to content

fix(GltfWriter): emit 32-bit glTF indices for models with >65535 vertices#901

Open
vladtrc wants to merge 1 commit into
Laupetin:mainfrom
vladtrc:fix/gltf-32bit-indices
Open

fix(GltfWriter): emit 32-bit glTF indices for models with >65535 vertices#901
vladtrc wants to merge 1 commit into
Laupetin:mainfrom
vladtrc:fix/gltf-32bit-indices

Conversation

@vladtrc

@vladtrc vladtrc commented Jul 11, 2026

Copy link
Copy Markdown

Problem

GltfWriter always writes face indices as unsigned short and hardcodes the index accessor componentType to UNSIGNED_SHORT, while the source field XModelFace::vertexIndex is 32-bit unsigned. All primitives of a model share a single global vertex buffer (positionAccessor.count == xmodel.m_vertices.size()), so for any XModel whose vertex buffer exceeds 65535 vertices every index >= 65536 is silently truncated modulo 65536. The exported glTF then references the wrong vertices and the mesh is corrupted, with no error emitted.

Affected sites in GltfWriter.cpp: the index bufferView byteLength, the index accessor componentType, the static_cast<unsigned short> index write in FillBufferData, and GetExpectedBufferSize.

Why it's real

  • XModelFace::vertexIndex is unsigned (32-bit); the writer narrows it to unsigned short.
  • static_cast<unsigned short>(N) == N mod 65536, so a face referencing vertex 70000 references vertex 4464 instead — deterministic.
  • glTF 2.0 permits componentType 5125 (UNSIGNED_INT) for indices; standard, no extension required.
  • Concrete data point: a merged map model reaches 235,386 vertices in a single buffer; under the current code all of its index sets are emitted as UNSIGNED_SHORT, so ~170k vertices are unaddressable. Individual large XModels in the later supported titles hit the same ceiling through the stock xmodel glTF dumper.

Fix

Select unsigned int / UNSIGNED_INT and the matching stride/buffer size when m_vertices.size() > 0xFFFF, keep the existing unsigned short path otherwise. LhcToRhcIndices is templated so the winding conversion stays a single source of truth for both index widths.

Test

Adds GltfWriterTest.cpp: builds a 70000-vertex XModelCommon with a face referencing vertex 69999, runs the writer through a mock gltf::Output, and asserts the index accessor is UNSIGNED_INT (5125) and the high index survives in the buffer unwrapped. Verified against the current tree: the test fails on the unpatched writer (componentType is 5123) and passes with the fix; the full ObjWritingTests suite stays green.

Disclosure

This bug was found, and this change prepared, with heavy use of AI agents while building an independent IW4 asset reader. The analysis, the byte-level reasoning and the vertex counts above are real and were verified against actual game data and the current source — nothing is fabricated. Happy to adjust to your preferred style or add/adapt the regression test.

XModelFace::vertexIndex is 32-bit and every glTF primitive shares one
global vertex buffer, but the writer hardcoded UNSIGNED_SHORT face
indices and the matching stride. Any index >= 65536 was truncated modulo
65536, silently corrupting the geometry of models with more than 65535
vertices.

Select UNSIGNED_INT (and the matching stride/buffer size) when the model
has more than 65535 vertices; keep the UNSIGNED_SHORT path otherwise.
Add a GltfWriter regression test covering the >65535-vertex case.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant