Honor VertexFormat for WavefrontVBOBuilder (#433)

This commit is contained in:
Jason Mitchell authored and GitHub committed 2026-07-27 20:34:17 -07:00
1 parent 6450ac4cd1
commit 9e79aedeab
9 files changed
+443 -16

No files matched your search

@@ -1,6 +1,9 @@
package com.gtnewhorizon.gtnhlib.client.model.wavefront;
import static com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFlags.NORMAL_BIT;
import static com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFlags.TEXTURE_BIT;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
@@ -10,7 +13,6 @@ import java.util.List;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.Tessellator;
import net.minecraft.client.resources.IResource;
import net.minecraft.util.ResourceLocation;
import net.minecraftforge.client.model.ModelFormatException;
@@ -23,6 +25,7 @@ import com.gtnewhorizon.gtnhlib.client.renderer.vao.IVertexArrayObject;
import com.gtnewhorizon.gtnhlib.client.renderer.vao.VertexBufferType;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.DefaultVertexFormat;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormat;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormatElement;
/**
* Wavefront Object importer Based heavily off of the specifications found at
@@ -32,7 +35,6 @@ public final class WavefrontVBOBuilder {
private final List<Vector3f> vertices = new ArrayList<>(64);
private final List<Vector2f> textureCoordinates = new ArrayList<>(64);
private final IVertexArrayObject vao;
static final int FORMAT_V_VT_VN = 0;
static final int FORMAT_V_VT = 1;
@@ -53,25 +55,62 @@ public final class WavefrontVBOBuilder {
return compileToVBO(resource, DefaultVertexFormat.POSITION_TEXTURE_NORMAL);
}
/**
* @throws IllegalArgumentException if the format declares an attribute an OBJ cannot supply; only position, UV and
* normal are fillable.
*/
public static IVertexArrayObject compileToVBO(ResourceLocation resource, VertexFormat format) {
return new WavefrontVBOBuilder(resource, format).vao;
}
validateFormat(format);
private WavefrontVBOBuilder(ResourceLocation resource, VertexFormat format) throws ModelFormatException {
try {
final IResource res = Minecraft.getMinecraft().getResourceManager().getResource(resource);
vao = loadObjModel(res.getInputStream(), format);
try (InputStream inputStream = Minecraft.getMinecraft().getResourceManager().getResource(resource)
.getInputStream()) {
final DirectTessellator tessellator = DirectTessellator.startCapturing(format);
try {
loadObjModel(inputStream, tessellator, format);
return tessellator.uploadToVBO(VertexBufferType.IMMUTABLE);
} finally {
DirectTessellator.stopCapturing();
}
} catch (IOException e) {
throw new ModelFormatException("IO Exception reading model format", e);
}
}
private IVertexArrayObject loadObjModel(InputStream inputStream, VertexFormat format) throws ModelFormatException {
/**
* Rejects formats whose attributes the OBJ loader cannot populate
*/
static void validateFormat(VertexFormat format) {
final VertexFormatElement[] elements = format.elementsArray;
if (!format.hasInlinePosition()) {
throw new IllegalArgumentException(
"Vertex format must start with a 3-float position element, got "
+ (elements.length == 0 ? "an empty format" : elements[0]));
}
for (int i = 1; i < elements.length; i++) {
final int bit = elements[i].getVertexBit();
if (bit != TEXTURE_BIT && bit != NORMAL_BIT) {
throw new IllegalArgumentException(
"Cannot fill vertex format element " + i
+ " "
+ elements[i]
+ " from an OBJ; only a leading position, UV and normal are supported");
}
}
}
static void loadObjModel(InputStream inputStream, DirectTessellator tessellator, VertexFormat format)
throws ModelFormatException {
new WavefrontVBOBuilder(format).parse(inputStream, tessellator);
}
private WavefrontVBOBuilder(VertexFormat format) {
hasTex = format.hasTexture();
hasNormals = format.hasNormals();
}
final DirectTessellator tessellator = DirectTessellator.startCapturing();
private void parse(InputStream inputStream, DirectTessellator tessellator) throws ModelFormatException {
BufferedReader reader = null;
String currentLine;
@@ -120,7 +159,9 @@ public final class WavefrontVBOBuilder {
} catch (IOException ignored) {}
}
return DirectTessellator.stopCapturingToVBO(VertexBufferType.IMMUTABLE);
if (format == -1) {
throw new ModelFormatException("Model contains no faces");
}
}
static Vector3f parseVertex(final String line, final int lineCount, final String[] tokens)
@@ -186,6 +227,28 @@ public final class WavefrontVBOBuilder {
this.vertexArr = new Vector3f[length];
this.texArr = new Vector2f[length];
format = determineFormat(tokens[0]);
if (hasTex && (format == FORMAT_V || format == FORMAT_V_VN)) {
throw new ModelFormatException(
"Model has no texture coordinates but the requested vertex format declares a UV attribute (line "
+ lineCount
+ ")");
}
} else {
if (length != this.vertexArr.length) {
throw new ModelFormatException(
"Face at line " + lineCount
+ " has "
+ length
+ " vertices but the model started with "
+ this.vertexArr.length
+ "; mixed face sizes are not supported");
}
if (determineFormat(tokens[0]) != format) {
throw new ModelFormatException(
"Face at line " + lineCount
+ " uses a different vertex reference format than the first face; mixed face formats are not supported");
}
}
final Vector3f[] vertices = this.vertexArr;
@@ -315,9 +378,9 @@ public final class WavefrontVBOBuilder {
}
}
// Remove trailing space if present
// Remove trailing space if present; resultLength is -1 for an all-whitespace line
final int resultLength = output.length() - 1;
if (output.charAt(resultLength) == ' ') {
if (resultLength >= 0 && output.charAt(resultLength) == ' ') {
output.setLength(resultLength);
}
}
@@ -429,11 +429,20 @@ public class DirectTessellator extends Tessellator {
// Method may change/get removed in the future.
// For future-proof use, use TessellatorManager#startCapturingDirect(VertexFormat)
/**
* @throws IllegalArgumentException if the format does not start with an inline 3-float position element (see
* {@link VertexFormat#hasInlinePosition()})
*/
@Deprecated
public final void setVertexFormat(VertexFormat format) {
if (this.format != null) {
throw new IllegalStateException("Cannot call setVertexFormat() after a vertex has already been emitted!");
}
if (!format.hasInlinePosition()) {
throw new IllegalArgumentException(
"Vertex format must start with a 3-float position element, got "
+ (format.elementsArray.length == 0 ? "an empty format" : format.elementsArray[0]));
}
this.preDefinedFormat = format;
this.format = format;
this.hasTexture = format.hasTexture();
@@ -304,6 +304,10 @@ public class TessellatorManager {
return directTessellatorIndex != -1;
}
public static int getDirectCaptureDepth() {
return directTessellatorIndex + 1;
}
private static void setDirectTessellator(DirectTessellator tessellator) {
if (++directTessellatorIndex >= DIRECT_TESSELLATOR_STACK_DEPTH) {
directTessellatorIndex--;
@@ -335,7 +339,12 @@ public class TessellatorManager {
public static DirectTessellator startCapturingDirect(VertexFormat format) {
final DirectTessellator tessellator = startCapturingDirect();
tessellator.setVertexFormat(format);
try {
tessellator.setVertexFormat(format);
} catch (Throwable t) {
stopCapturingDirect();
throw t;
}
return tessellator;
}
@@ -873,7 +882,7 @@ public class TessellatorManager {
/**
* Fast path to get the Tessellator used in the main thread. Does not check if this is called on the main thread.
* This should only be used if you are 100% certain that it only gets called in the main thread.
*
*
* @return Tessellator.instance, or DirectTessellator if currently capturing.
*/
public static Tessellator getMainThreadTessellator() {
@@ -14,6 +14,7 @@ import org.joml.Vector3f;
import com.gtnewhorizon.gtnhlib.client.renderer.cel.model.quad.ModelQuadView;
import com.gtnewhorizon.gtnhlib.client.renderer.cel.model.quad.ModelQuadViewMutable;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormatElement.Type;
import it.unimi.dsi.fastutil.objects.ObjectImmutableList;
import lombok.Getter;
@@ -245,6 +246,15 @@ public class VertexFormat {
return this.vertexFlags;
}
public final boolean hasInlinePosition() {
if (elementsArray.length == 0) return false;
final VertexFormatElement first = elementsArray[0];
return first.getWriter().isInlinePosition() && first.getType() == Type.FLOAT
&& first.getCount() == 3
&& first.getByteSize() == 12;
}
public final boolean hasTexture() {
return (vertexFlags & TEXTURE_BIT) != 0;
}
@@ -40,6 +40,11 @@ public class VertexFormatElement {
this.writer = writer;
}
@Override
public String toString() {
return usage.getName() + "[" + type.getName() + " x" + count + ", index=" + index + ", " + byteSize + " bytes]";
}
public void setupBufferState(long offset, int stride) {
this.usage.setupBufferState(this.count, this.type.getGlType(), stride, offset, this.index);
}
@@ -14,6 +14,10 @@ public interface IVertexAttributeWriter {
// Populate the Tessellator using the buffer's contents
int readAttribute(long pointer, Tessellator tessellator);
default boolean isInlinePosition() {
return false;
}
default int writeAttributeTransformed(long pointer, int[] data, int index, Matrix4fc transform, Vector3f scratch) {
return writeAttribute(pointer, data, index);
}
@@ -41,4 +41,9 @@ public final class PositionVertexAttributeWriter implements IVertexAttributeWrit
throw new UnsupportedOperationException("Cannot read position from tessellator!");
}
@Override
public boolean isInlinePosition() {
return true;
}
}
@@ -0,0 +1,305 @@
package com.gtnewhorizon.gtnhlib.client.model.wavefront;
import static com.gtnewhorizon.gtnhlib.bytebuf.MemoryUtilities.*;
import static com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFlags.*;
import static org.junit.jupiter.api.Assertions.*;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import net.minecraft.client.renderer.Tessellator;
import net.minecraftforge.client.model.ModelFormatException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.lwjgl.opengl.GL11;
import com.gtnewhorizon.gtnhlib.client.renderer.DirectTessellator;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.DefaultVertexFormat;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormat;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormatElement;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormatElement.Type;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormatElement.Usage;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.writers.IVertexAttributeWriter;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.writers.PositionVertexAttributeWriter;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.writers.TextureVertexAttributeWriter;
public class WavefrontVBOBuilderTest {
private static final int A_POSITION = 3;
private static final int A_UV = 7;
private static final VertexFormat GENERIC_POSITION_UV = new VertexFormat(
new VertexFormatElement(
A_POSITION,
Type.FLOAT,
Usage.GENERIC,
3,
POSITION_BIT,
new PositionVertexAttributeWriter()),
new VertexFormatElement(
A_UV,
Type.FLOAT,
Usage.GENERIC,
2,
TEXTURE_BIT,
new TextureVertexAttributeWriter()));
private static final String TRIANGLE_OBJ = "v 0 0 0\n" + "v 1 0 0\n"
+ "v 0 1 0\n"
+ "vt 0 0\n"
+ "vt 1 0\n"
+ "vt 0 1\n"
+ "f 1/1 2/2 3/3\n";
private static final String QUAD_OBJ = "v 0 0 0\n" + "v 1 0 0\n"
+ "v 1 1 0\n"
+ "v 0 1 0\n"
+ "vt 0 0\n"
+ "vt 1 0\n"
+ "vt 1 1\n"
+ "vt 0 1\n"
+ "f 1/1 2/2 3/3 4/4\n";
private static final float UV_DELTA = 0.001f;
/** A 12-byte float3 that is not a position, to prove the guard looks at more than the element's shape. */
private static final IVertexAttributeWriter NOT_POSITION = new IVertexAttributeWriter() {
@Override
public int writeAttribute(long pointer, int[] data, int index) {
return 12;
}
@Override
public int writeAttribute(long pointer, Tessellator tessellator) {
return 12;
}
@Override
public int readAttribute(long pointer, Tessellator tessellator) {
return 12;
}
};
private final List<DirectTessellator> allocated = new ArrayList<>();
@AfterEach
void freeTessellators() {
allocated.forEach(DirectTessellator::delete);
allocated.clear();
}
private static InputStream obj(String source) {
return new ByteArrayInputStream(source.getBytes(StandardCharsets.UTF_8));
}
private DirectTessellator tessellator(VertexFormat format) {
final DirectTessellator tess = new DirectTessellator(1024);
allocated.add(tess);
if (format != null) tess.setVertexFormat(format);
return tess;
}
private DirectTessellator build(String source, VertexFormat parseFormat, VertexFormat predefinedFormat) {
final DirectTessellator tess = tessellator(predefinedFormat);
WavefrontVBOBuilder.loadObjModel(obj(source), tess, parseFormat);
return tess;
}
@Test
void testGenericFormatIsHonoured() {
assertEquals(20, GENERIC_POSITION_UV.getVertexSize(), "3 floats of position + 2 floats of UV");
final DirectTessellator tess = build(TRIANGLE_OBJ, GENERIC_POSITION_UV, GENERIC_POSITION_UV);
assertSame(GENERIC_POSITION_UV, tess.getVertexFormat(), "The format passed in must be the one used");
assertEquals(3, tess.getVertexCount());
assertEquals(60, tess.getWriteBuffer().limit(), "3 vertices at the custom 20-byte stride");
final long base = memAddress0(tess.getWriteBuffer());
assertEquals(0f, memGetFloat(base), 0.0001f);
assertEquals(0f, memGetFloat(base + 4), 0.0001f);
assertEquals(0f, memGetFloat(base + 8), 0.0001f);
assertEquals(0f, memGetFloat(base + 12), UV_DELTA);
assertEquals(1f, memGetFloat(base + 16), UV_DELTA);
assertEquals(1f, memGetFloat(base + 20), 0.0001f);
assertEquals(0f, memGetFloat(base + 24), 0.0001f);
assertEquals(1f, memGetFloat(base + 32), UV_DELTA);
assertEquals(1f, memGetFloat(base + 36), UV_DELTA);
assertEquals(0f, memGetFloat(base + 40), 0.0001f);
assertEquals(1f, memGetFloat(base + 44), 0.0001f);
assertEquals(0f, memGetFloat(base + 52), UV_DELTA);
assertEquals(0f, memGetFloat(base + 56), UV_DELTA);
}
@Test
void testGenericAttributeLocationsSurvive() {
final VertexFormat used = build(TRIANGLE_OBJ, GENERIC_POSITION_UV, GENERIC_POSITION_UV).getVertexFormat();
assertEquals(A_POSITION, used.elementsArray[0].getIndex());
assertEquals(A_UV, used.elementsArray[1].getIndex());
assertEquals(Usage.GENERIC, used.elementsArray[1].getUsage());
}
@Test
void testDerivedFormatCannotExpressGenericAttributes() {
final VertexFormat derived = build(TRIANGLE_OBJ, GENERIC_POSITION_UV, null).getVertexFormat();
assertSame(DefaultVertexFormat.POSITION_TEXTURE, derived);
assertEquals(GENERIC_POSITION_UV.getVertexSize(), derived.getVertexSize(), "Same stride, different bindings");
assertEquals(Usage.PRIMARY_UV, derived.elementsArray[1].getUsage(), "Binds through glTexCoordPointer");
}
@Test
void testDefaultOverloadIsUnchanged() {
final VertexFormat format = DefaultVertexFormat.POSITION_TEXTURE_NORMAL;
final DirectTessellator derived = build(TRIANGLE_OBJ, format, null);
final DirectTessellator predefined = build(TRIANGLE_OBJ, format, format);
assertSame(format, derived.getVertexFormat(), "The derived format was already POSITION_TEXTURE_NORMAL");
assertSame(format, predefined.getVertexFormat());
final ByteBuffer a = derived.getWriteBuffer();
final ByteBuffer b = predefined.getWriteBuffer();
assertEquals(72, a.limit(), "3 vertices at the 24-byte POSITION_TEXTURE_NORMAL stride");
assertEquals(a.limit(), b.limit());
for (int i = 0; i < a.limit(); i++) {
assertEquals(a.get(i), b.get(i), "Byte " + i + " differs from the pre-fix output");
}
}
@Test
void testQuadsKeepCustomStride() {
final DirectTessellator tess = build(QUAD_OBJ, GENERIC_POSITION_UV, GENERIC_POSITION_UV);
assertEquals(GL11.GL_QUADS, tess.getDrawMode());
assertEquals(4, tess.getVertexCount());
assertEquals(80, tess.getWriteBuffer().limit());
}
@Test
void testUnfillableAttributeIsRejected() {
assertThrows(
IllegalArgumentException.class,
() -> WavefrontVBOBuilder.validateFormat(DefaultVertexFormat.POSITION_TEXTURE_COLOR),
"An OBJ cannot supply a color channel");
assertThrows(
IllegalArgumentException.class,
() -> WavefrontVBOBuilder.validateFormat(DefaultVertexFormat.POSITION_TEXTURE_LIGHT),
"An OBJ cannot supply a brightness channel");
assertDoesNotThrow(() -> WavefrontVBOBuilder.validateFormat(DefaultVertexFormat.POSITION_TEXTURE_NORMAL));
assertDoesNotThrow(() -> WavefrontVBOBuilder.validateFormat(GENERIC_POSITION_UV));
}
@Test
void testFormatNotStartingWithPositionIsRejected() {
final VertexFormat uvFirst = new VertexFormat(
DefaultVertexFormat.TEXTURE_ELEMENT,
DefaultVertexFormat.POSITION_ELEMENT);
assertThrows(IllegalArgumentException.class, () -> WavefrontVBOBuilder.validateFormat(uvFirst));
assertThrows(
IllegalArgumentException.class,
() -> tessellator(uvFirst),
"setVertexFormat guards the same invariant for every predefined-format caller");
}
@Test
void testTexturedFormatAgainstUntexturedModel() {
final String positionsOnly = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 3\n";
assertThrows(
ModelFormatException.class,
() -> build(positionsOnly, DefaultVertexFormat.POSITION_TEXTURE, DefaultVertexFormat.POSITION_TEXTURE),
"Must report the mismatch rather than dereferencing the unfilled UV array");
}
@Test
void testModelWithoutFaces() {
assertThrows(
ModelFormatException.class,
() -> build("v 0 0 0\nv 1 0 0\n", GENERIC_POSITION_UV, GENERIC_POSITION_UV));
}
@Test
void testMalformedFaceStillPropagates() {
final String badIndex = "v 0 0 0\nv 1 0 0\nv 0 1 0\nf 1 2 99\n";
assertThrows(
IndexOutOfBoundsException.class,
() -> build(badIndex, DefaultVertexFormat.POSITION, DefaultVertexFormat.POSITION));
}
@Test
void testFloat3NonPositionFirstElementIsRejected() {
final VertexFormat normalFirst = new VertexFormat(
new VertexFormatElement(0, Type.FLOAT, Usage.GENERIC, 3, NORMAL_BIT, NOT_POSITION),
new VertexFormatElement(
A_UV,
Type.FLOAT,
Usage.GENERIC,
2,
TEXTURE_BIT,
new TextureVertexAttributeWriter()));
assertFalse(
normalFirst.hasInlinePosition(),
"Element 0 is written as inline xyz, so a float3 that is not a position must not claim that slot");
assertThrows(IllegalArgumentException.class, () -> WavefrontVBOBuilder.validateFormat(normalFirst));
assertThrows(IllegalArgumentException.class, () -> tessellator(normalFirst));
}
@Test
void testWhitespaceOnlyLinesAreIgnored() {
final String padded = "v 0 0 0\n \n" + "v 1 0 0\n\t\n"
+ "v 0 1 0\n"
+ "vt 0 0\n"
+ "vt 1 0\n"
+ "vt 0 1\n"
+ " \n"
+ "f 1/1 2/2 3/3\n";
final ByteBuffer clean = build(TRIANGLE_OBJ, GENERIC_POSITION_UV, GENERIC_POSITION_UV).getWriteBuffer();
final ByteBuffer spaced = build(padded, GENERIC_POSITION_UV, GENERIC_POSITION_UV).getWriteBuffer();
assertEquals(clean.limit(), spaced.limit());
for (int i = 0; i < clean.limit(); i++) {
assertEquals(clean.get(i), spaced.get(i), "Byte " + i + " differs from the unpadded model");
}
}
@Test
void testMixedFaceSizesRejected() {
final String mixed = "v 0 0 0\nv 1 0 0\nv 1 1 0\nv 0 1 0\nf 1 2 3\nf 1 2 3 4\n";
assertThrows(
ModelFormatException.class,
() -> build(mixed, DefaultVertexFormat.POSITION, DefaultVertexFormat.POSITION),
"A single VBO carries one draw mode, so the quad cannot follow the triangle");
}
@Test
void testMixedFaceFormatsRejected() {
final String mixed = "v 0 0 0\nv 1 0 0\n" + "v 0 1 0\n"
+ "vt 0 0\n"
+ "vt 1 0\n"
+ "vt 0 1\n"
+ "f 1/1 2/2 3/3\n"
+ "f 1 2 3\n";
assertThrows(
ModelFormatException.class,
() -> build(mixed, GENERIC_POSITION_UV, GENERIC_POSITION_UV),
"The second face has no UV indices; reading stale subtokens would silently corrupt the UVs");
}
}
@@ -13,6 +13,8 @@ import com.gtnewhorizon.gtnhlib.client.renderer.CapturingTessellator;
import com.gtnewhorizon.gtnhlib.client.renderer.TessellatorManager;
import com.gtnewhorizon.gtnhlib.client.renderer.cel.model.primitive.ModelPrimitiveView;
import com.gtnewhorizon.gtnhlib.client.renderer.cel.model.quad.ModelQuadViewMutable;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.DefaultVertexFormat;
import com.gtnewhorizon.gtnhlib.client.renderer.vertex.VertexFormat;
/**
* Unit tests for TessellatorManager compiling mode and capturing mode.
@@ -25,6 +27,21 @@ public class TessellatorManagerTest {
TessellatorManager.cleanup();
}
@Test
void testRejectedFormatDoesNotLeakTheStack() {
final VertexFormat uvFirst = new VertexFormat(
DefaultVertexFormat.TEXTURE_ELEMENT,
DefaultVertexFormat.POSITION_ELEMENT);
final int depth = TessellatorManager.getDirectCaptureDepth();
assertThrows(IllegalArgumentException.class, () -> TessellatorManager.startCapturingDirect(uvFirst));
assertEquals(
depth,
TessellatorManager.getDirectCaptureDepth(),
"Nothing should be left on the DirectTessellator stack");
}
@Test
void testSetCompilingWithNullCallback() {
assertThrows(