Movatterモバイル変換


[0]ホーム

URL:


Skip to content
DEV Community
Log in Create account

DEV Community

ndesmic
ndesmic

Posted on • Edited on

WebGL Engine from Scratch 13: OBJ Format

One of the biggest limitations that the engine has right now is that it can only generate geometry with algorithms. However, to be more useful we'd like to be able to load custom-made meshes made by artists. There's a few different formats that do different things but.obj is probably the simplest and most widely used. This was a format created by Wavefront and has become a defacto standard for simple meshes. It's easy enough to write a loader for it and that's what we'll be focusing on in this post. Note that the loader won't handle all features of OBJ because it gets into 3D Splines and such that our engine just doesn't deal with but we'll get positions, normals and UVs which should still allow a large amount of.obj files.

Create a loader

.obj files are text files where each line represents a piece of data, usually a point of some sort. Each line start with a short code that describes what the point is.

Example

v -3.000000 1.800000 0.000000
Enter fullscreen modeExit fullscreen mode

For this we'll be concerned with positions, normals and UVs.

  • vertex position =v
  • vertex UV =vt
  • vertex normal =vn

Followed are a couple of space delimited values. These could be variable in length, but we'll assume they are 3d and there will be 3 points. This will give us a "pool" of points. This will then be followed by instructions to construct facesf. This is very similar to how the index-buffer works.

f 2909 2921 2939
Enter fullscreen modeExit fullscreen mode

This basically says "construct a triangle" with vertices 2909, 2921 and 2939.Be careful here, face indices are1-indexed! This means that the first vertex is 1 not 0 so we'll wind up subtracting 1 from them. You can also match up specific types of vertices.

f 1/2/2 2/3/1 3/4/2
Enter fullscreen modeExit fullscreen mode

This means vertex 1 is a combination of position 1, UV 2, normal 2. vertex 2 is a combination of position 2, UV 3, normal 1 etc. If not specified in this format, it uses the same index for all parts. Primitives beyond triangles (e.g. quads) are allowed but our engine only supports triangles so we can assume they will be 3 points. For simplicity, so that we can use our existing data format, we'll ignore these multi-vertex formats.

Here's what I came up with:

exportfunctionloadObj(txt){constpositions=[];constnormals=[];constuvs=[];consttriangles=[];constcolors=[];letv=0;constlines=txt.split("\n");for(constlineoflines){constnormalizedLine=line.trim();if(!normalizedLine||normalizedLine.startsWith("#"))continue;constparts=normalizedLine.split(/\s+/g);constvalues=parts.slice(1).map(x=>parseFloat(x));switch(parts[0]){case"v":{positions.push(...values);break;}case"c":{//custom extensioncolors.push(...values);break;}case"vt":{uvs.push(...values);break;}case"vn":{normals.push(...values);break;}case"f":{triangles.push(...values.map(x=>x-1));break;}}}return{positions,uvs,normals,triangles,colors};}
Enter fullscreen modeExit fullscreen mode

You'll notice here that I added a new entity typec.This is not part of the.obj specification! This is because playing around with simple coloring can be useful for debugging especially when you are trying to figure out vertex ordering. Any files you create withc may not be readable by other.obj readers. We also can throw out any lines starting with# as those are comments.

I also created a small helper function which does some of the normal fetch plumbing (this could perhaps be extended to include images/shaders/loading meshes directly):

exportasyncfunctionloadUrl(url,type="text"){constres=awaitfetch(url);switch(type){case"text":returnres.text();case"blob":returnres.blob();case"arrayBuffer":returnres.arrayBuffer();}}
Enter fullscreen modeExit fullscreen mode

Simple pyramid

Note from here on I will be adding some scaling and translation to make the shapes visible:

mesh.setScale({x:0.5,y:0.5,z:0.5}).setTranslation({y:-0.5})
Enter fullscreen modeExit fullscreen mode

For our first test let's try something a little more human readable, a pyramid:

v  0  0  0v  1  0  0v  1  1  0v  0  1  0v  0.5  0.5  1.6#custom extension for debugging!c 1 0 0c 0 1 0c 0 0 1c 1 1 0c 0 1 1f  5  2  3f  4  5  3f  6  3  2f  5  6  2f  4  6  5f  6  4  3
Enter fullscreen modeExit fullscreen mode

I found this simple example herehttps://people.sc.fsu.edu/~jburkardt/data/obj/pyramid.obj and extended it with color so it's easier to visually debug.

Also for debugging, I also used a simple shader that uses colors so we don't need to worry about lighting:

//color.vert.glsluniformmat4uProjectionMatrix;uniformmat4uModelMatrix;uniformmat4uViewMatrix;attributevec3aVertexPosition;attributevec3aVertexColor;varyingmediumpvec4vColor;voidmain(){gl_Position=uProjectionMatrix*uViewMatrix*uModelMatrix*vec4(aVertexPosition,1.0);vColor=vec4(aVertexColor,1.0);}//color.frag.glslvaryingmediumpvec4vColor;voidmain(){gl_FragColor=vColor;}
Enter fullscreen modeExit fullscreen mode

And our pyramid:

Simple cube

For our second test let's get a little more complicated.

v   -0.5 -0.5 -0.5v   0.5 -0.5 -0.5v   0.5 0.5 -0.5v   -0.5 0.5 -0.5v   0.5 -0.5 -0.5v   0.5 -0.5 0.5v   0.5 0.5 0.5v   0.5 0.5 -0.5v   0.5 -0.5 0.5v   -0.5 -0.5 0.5v   -0.5 0.5 0.5v   0.5 0.5 0.5v   -0.5 -0.5 0.5v   -0.5 -0.5 -0.5v   -0.5 0.5 -0.5v   -0.5 0.5 0.5v   -0.5 0.5 -0.5v   0.5 0.5 -0.5v   0.5 0.5 0.5v   -0.5 0.5 0.5v   -0.5 -0.5 0.5v   0.5 -0.5 0.5v   0.5 -0.5 -0.5v   -0.5 -0.5 -0.5#custom color extensions for debuggingc  1 0 0c  1 0 0c  1 0 0c  1 0 0c  0 1 0c  0 1 0c  0 1 0c  0 1 0c  0 0 1c  0 0 1c  0 0 1c  0 0 1c  1 1 0c  1 1 0c  1 1 0c  1 1 0c  0 1 1c  0 1 1c  0 1 1c  0 1 1c  1 0 1c  1 0 1c  1 0 1c  1 0 1vt  0 0vt  1 0vt  1 1vt  0 1vt  0 0vt  1 0vt  1 1vt  0 1vt  0 0vt  1 0vt  1 1vt  0 1vt  0 0vt  1 0vt  1 1vt  0 1vt  0 0vt  1 0vt  1 1vt  0 1vt  0 0vt  1 0vt  1 1vt  0 1vn  0.0 0.0 -1.0vn  0.0 0.0 -1.0vn  0.0 0.0 -1.0vn  0.0 0.0 -1.0vn  1.0 0.0 0.0vn  1.0 0.0 0.0vn  1.0 0.0 0.0vn  1.0 0.0 0.0vn  0.0 0.0 1.0vn  0.0 0.0 1.0vn  0.0 0.0 1.0vn  0.0 0.0 1.0vn  -1.0 0.0 0.0vn  -1.0 0.0 0.0vn  -1.0 0.0 0.0vn  -1.0 0.0 0.0vn  0.0 1.0 0.0vn  0.0 1.0 0.0vn  0.0 1.0 0.0vn  0.0 1.0 0.0vn  0.0 -1.0 0.0vn  0.0 -1.0 0.0vn  0.0 -1.0 0.0vn  0.0 -1.0 0.0f   1 2 3f   1 3 4f   5 6 7f   5 7 8f   9 10 11 f   9 11 12f   13 14 15f   13 15 16 f   17 18 19f   17 19 20f   21 22 23f   21 23 24
Enter fullscreen modeExit fullscreen mode

This is a direct translation of the cube fromdata.js (with+1 added to the indices). Note that we could have made this slightly more compact. If you remember we have to duplicate points on hard-edged figures like cubes as they have different normals in each face. Thef command lets us mix normals and UVs with positions as explained above. To do this we'd have to generate all those implied points. We won't be doing that today.

This produces the output:

The teapot

Let's use a more complicated mesh. In typically fashion for 3D tutorials and testing I'm going to use theUtah Teapot. This is because it's complex and yet everyone else uses it so it's easy to compare implementations to see if they look the same. The version I found was here:https://raw.githubusercontent.com/jaz303/utah-teapot/master/teapot.obj

I've added a parameter to the object loader that allows us to add a color to each vertex since this mesh has way more vertices than we can hand edit colors for:

exportfunctionloadObj(txt,color){//...switch(parts[0]){case"v":{positions.push(...values);colors.push(...color);break;}case"c":{//custom extensionif(!color){colors.push(...values);}break;}//...}
Enter fullscreen modeExit fullscreen mode

If color is specified it overrides the extended color values.

Now if we draw a red teapot:

Nice!

Although when we try to apply pixel shading:

This is because the file contains no normals. Using advanced geometry processing would could maybe recover them but for now we'd just have to find a better file I guess.

I did find another example that thankfully didn't use complex vertex form:

https://people.sc.fsu.edu/~jburkardt/data/obj/teapot.obj

One aspect is also troubling:

We can see through it at some angles. We can fix this by turning backface-culling off, but that's not really a good fix. The problem is that the vertex winding order is not right. Here I found a sort of inconsistency in my test meshes, the order in the faces is supposed to be counter-clock-wise, but sometimes it was clock-wise. The teapot we're rendering here needs the vertices reversed. But the teapot with normals did not.

I made a small update to theobj-loader to let the user specify:

//reverseWinding is a boolean parametercase"f":{constoneBasedIndicies=values.map(x=>x-1);triangles.push(...(reverseWinding?oneBasedIndicies.reverse():oneBasedIndicies));break;}
Enter fullscreen modeExit fullscreen mode

With that things look fixed:

However if we look down there's still some issues if you look from the top.

These appear to just be a defect in the model, at least it makes intuitive sense why these wouldn't show correctly. Again, turning off backface culling can fix this.

Finally using a pixel-shaded lighting on the teapot with normals we get:

This seems good enough.

Diversion #1: normalizing a mesh

As I was testing different.obj files I found that they are all over the place in terms of size which required a lot of transforming to get right. To make this process easier I created a new method inMesh that scales it down to a 1x1x1 unit volume.

normalizePositions(){letmax=-Infinity;for(leti=0;i<this.#positions.lengthconstx=this.#positions[i];consty=this.#positions[i+1];constz=this.#positions[i+2];if(x>max){max=x;}if(y>max){max=y;}if(z>max){max=z;}}for(leti=0;i<this.#positions.lengththis.#positions[i]/=max;}returnthis;}
Enter fullscreen modeExit fullscreen mode

All we do is find the maximum length and then scale everything else relative to it. This means we can instantly get a model that is zoomed in correctly. We could choose to apply this to the model matrix instead of the positions themselves like I did. I'm not sure if that would be better, but this works.

Diversion 2: Adding a screen capture button

Before I was just getting screen grabs which were unevenly cropped. To make things a little better let's add a screen capture button.

I just create a new button and wire it up to an event handler. Now, naively we'd expect that we could just docanvas.toDataURL() and download that using thea tag trick eg:

exportfunctiondownloadUrl(url,fileName){constlink=document.createElement("a");link.href=url;link.download=fileName;link.click();}
Enter fullscreen modeExit fullscreen mode

The problem is this will likely result in an empty image. It'll have the exact same dimensions but it'll be entirely empty. The reason for this is because thetoDataURL happens in a different event. The canvas buffer is cleared before that event is run. So we have two options: We could usepreserveDrawingBuffer = true when creating the canvas context to preserve it. However, I think this might have unintended consequences if we later try something fancy with the buffer. Instead we just need to move thetoDataUrl to within the draw loop, that is, therender method.

Our button handler just becomes:

this.#shouldCapture=true;
Enter fullscreen modeExit fullscreen mode

And then at the very bottom of the render method:

if(this.#shouldCapture){this.#shouldCapture=false;downloadUrl(this.dom.canvas.toDataURL(),"capture.png");}
Enter fullscreen modeExit fullscreen mode

This just sets a flag so that the next time we render we capture at the end of the render sequence and reset the flag.

Final code for this post can be found here:https://github.com/ndesmic/geogl/tree/v9

References

Top comments(2)

Subscribe
pic
Create template

Templates let you quickly answer FAQs or store snippets for re-use.

Dismiss
CollapseExpand
 
lkatkus profile image
Laimonas K
I am an architect (the building kind) who also happens to be a JavaScript developer.
  • Location
    Lithuania
  • Work
    Software engineer
  • Joined

Hey! Thanks for this amazing series! Any plans on doing something related to loading animated models?

CollapseExpand
 
ndesmic profile image
ndesmic
I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
  • Joined

Eventually, someday. I'd love to build up to GLTF support.

Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment'spermalink.

For further actions, you may consider blocking this person and/orreporting abuse

I like to make fun web things from scratch. Ideally build-less, framework-less, infrastructure-less and free from the annoyances of my day job.
  • Joined

More fromndesmic

DEV Community

We're a place where coders share, stay up-to-date and grow their careers.

Log in Create account

[8]ページ先頭

©2009-2025 Movatter.jp