-
-
Notifications
You must be signed in to change notification settings - Fork 3.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
.mtl files support for .obj files to render color in 3D custom geometry #6710
.mtl files support for .obj files to render color in 3D custom geometry #6710
Conversation
@davepagurek The error above in the screenshots occur only when an obj file with an mtl file associated with it is loaded. The sketch used is:
Requesting a review so that I can proceed further and also guidance in solving the current error. |
a3951d1
to
2143dba
Compare
Sorry for the commit messages, I thought I had already committed some other changes earlier. |
…y/p5.js into Mtl-support-to-obj-files
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work so far! I've left a few comments, some about the error you were running into, and some about how we build up the model after that. Let me know what you think!
src/webgl/loading.js
Outdated
return new Promise((resolve,reject)=>{ | ||
// console.log('parser is called'); | ||
let currentMaterial = null; | ||
p5.prototype.loadStrings( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think rather than calling this on p5.prototype
, which will have no this
, you'd want to call loadStrings
on a p5 instance. That could mean calling await parseMtl(this, mtlPath);
inside loadModel
and then call loadStrings
on the first argument, although it might be a nicer separation of concerns to separate loading the strings and the parsing, so it becomes:
this.loadStrings(
mtlPath,
(mtlLines) => {
const materials = parseMtl(mtlLines);
parseObj(model, lines, materials)
},
() => {
// handle failure
}
)
Another benefit of this is that you maybe don't need parseMtl
to resolve or reject, since the loadStrings
part handles the case where it can't fetch the file, so parseMtl
can look more like parseObj
.
src/webgl/loading.js
Outdated
@@ -103,6 +103,8 @@ import './p5.Geometry'; | |||
* @param {String} [fileType] | |||
* @return {p5.Geometry} the <a href="#/p5.Geometry">p5.Geometry</a> object | |||
*/ | |||
|
|||
let materials = {}; //currently local |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we use this btw? Looks like we always pass in materials as a parameter
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, yes I had to remove it , missed it though.
src/webgl/loading.js
Outdated
if (normalize) { | ||
model.normalize(); | ||
async lines => { | ||
const mtlLine = lines.find(line => line.startsWith('mtllib ')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the obj spec supports multiple mtls, so we might need to filter
to get all the lines that have mtllib
and then parse all of them (maybe waiting for them all via Promise.all(...)
?)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought there could be just one mtl file to an obj file. After searching it through, yes there can be more. filter
should be used . Thanks.
lines => { | ||
for (let line = 0; line < lines.length; ++line){ | ||
const tokens = lines[line].trim().split(/\s+/); | ||
if(tokens[0] === 'newmtl') { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work on this parser, looks great!
src/webgl/loading.js
Outdated
currentMaterial = tokens[1]; | ||
if (currentMaterial && materials[currentMaterial]) { | ||
const diffuseColor = materials[currentMaterial].diffuseColor; | ||
model.vertexColors.push([ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we want to add to the diffuse colors for each vertex, where currently this is adding to the array once for each usemtl
line. So maybe that means just setting the currentMaterial
in this if
branch, and in the branch below where we add to loadedVerts
, add to vertexColors
based on the material.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh yes, the logic in my head was a little haywire and that's why I added to the array to vertexColors
for each usemtl
. Got your point, thanks for explaining the changes to be made also.
src/webgl/loading.js
Outdated
diffuseColor[2] | ||
]); | ||
} else { | ||
model.vertexColors.push([1, 1, 1]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just thinking this through: when a model has vertex colors, then fill()
has no effect on the model, unless you call .clearColors()
on it. Currently, all imported models have no colors, so they take on whatever fill is used. We want to keep that behaviour for objs that have no materials, so maybe that means if no materials are present in the obj, rather than pushing to vertexColors
, we just leave it empty.
This would make for a good unit test btw: testing that if you import an obj that has materials, it uses the material colors when you draw it with model()
, and when you import an obj that has no materials, it draws using whatever the fill color is.
@@ -0,0 +1,23 @@ | |||
mtllib octa-color.mtl |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The plant obj is 9mb, which might slow down tests. Maybe for the missing material test, we can make a duplicate of this colored obj, but change the path from octa-color.mtl
to something that doesn't exist?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes, that's what I had thought of too, made the same changes already for plant.obj
and will push them asap.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, we also recently merged in support for visual tests. That might be a good fit here to check that vertex colors are visible on canvas when drawing.
src/webgl/loading.js
Outdated
mtlPath=tokens[1]; | ||
} | ||
// Check if the MTL file has already been loaded | ||
const existingMtl = mtlFiles.find(mtl => mtl.path === mtlPath); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a bit of a race condition, since we only push to the array after await
ing an asynchronous function. Maybe you can synchronously push an object that just includes the path, and then after await
ing, add the materials to it? something like:
const file = { path: mtlPath };
mtlFiles.push(file);
const parsedMaterials = await parseMtl(self, mtlPath)
file.materials = parsedMaterials;
src/webgl/loading.js
Outdated
//Texture path | ||
materials[currentMaterial].texturePath = tokens[1]; | ||
} | ||
async function parseMtl(self,mtlPath){ //accepts mtlPath to load file |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can call self
p5
instead so it's clearer what its type should be?
src/webgl/loading.js
Outdated
async function parseMtl(self,mtlPath){ //accepts mtlPath to load file | ||
let materials= {}; | ||
let currentMaterial = null; | ||
self.loadStrings( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this function completes without waiting for the result of loadStrings
now that it no longer returns a promise. So I think that means it will immediately return {}
, and then asynchronously, properties will get added to it. I'm guessing it's important to have the complete materials object before continuing to parse .obj files later, so maybe it would make sense to add back in something like you had before?
const materials = {};
const doneLoading = new Promise((resolve, reject) => {
self.loadStrings(
mtlPath,
lines => {
// do parsing and add to materials here
resolve();
},
reject
)
});
await doneLoading;
return materials;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@davepagurek I changed my multiple mtl file support functionality to this:
else if (fileType.match(/\.obj$/i)) {
this.loadStrings(
path,
async lines => {
const parsedMaterialPromises=[];
try{
for (let i = 0; i < lines.length; i++) {
const mtllibMatch = lines[i].match(/^mtllib (.+)/);
if (mtllibMatch) {
let mtlPath='';
const mtlFilename = mtllibMatch[1];
const objPathParts = path.split('/');
if(objPathParts.length > 1){
objPathParts.pop();
const objFolderPath = objPathParts.join('/');
mtlPath = objFolderPath + '/' + mtlFilename;
}else{
mtlPath = mtlFilename;
}
const parsedMaterialsIndividual = await parseMtl(self, mtlPath);
parsedMaterialPromises.push(parsedMaterialsIndividual);
}
}
if(parsedMaterialPromises.length>0){
const parsedMaterialsArray=
await Promise.all(parsedMaterialPromises);
const parsedMaterials = Object.assign({}, ...parsedMaterialsArray);
await parseObj(model, lines, parsedMaterials);
} }catch (error) {
if (failureCallback) {
failureCallback(error);
} else {
p5._friendlyError('Error during parsing: ' + error.message);
}
}
finally{
if (normalize) {
model.normalize();
}
self._decrementPreload();
if (typeof successCallback === 'function') {
successCallback(model);
}
}
},
failureCallback
);
} else {
p5._friendlyFileLoadError(3, path);
if (failureCallback) {
failureCallback();
} else {
p5._friendlyError(
'Sorry, the file type is invalid. Only OBJ and STL files are supported.'
);
}
}
return model;
};
now single mtl file' materials are fully loaded first and then only passed to parseObj but for multiple files, materials are still being passed before fully loading thus the error: p5.js says: Error during parsing: Cannot read properties of undefined (reading 'diffuseColor')
In the earlier approach even after adding resolve, reject back and some other changes too, still the parseObj was called beforehand.
where is the logic breaking in the current code that single mtl files are passed after materials fully loading but not multiple mtl files? I'm trying to still find since I used Promise.all
also.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i tried using a counter to ensure parseObj is called after parsedMaterials are loaded like this:
const parsedMaterialsIndividual = await parseMtl(self, mtlPath);
mtlPaths.push(mtlPath);
mtlfileno++;
parsedMaterialPromises.push(parsedMaterialsIndividual);
and then,
const parsedMaterials =
await Object.assign({}, ...parsedMaterialsArray);
console.log(parsedMaterials);
if(mtlfileno===mtlPaths.length){
parseObj(model, lines, parsedMaterials);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A few little things:
- Right now you do
const parsedMaterialsIndividual = await parseMtl(self, mtlPath);
. Byawait
ing this, this means synchronously waiting for the material to be loaded/parsed, and the returned value is the actual result and not a promise. This works, but it means all mtl files are loaded one after another, and thePromise.all
has no effect due to the results not being promises any more. If you remove theawait
on this line, the result is a promise, and the code only waits for them to finish at thePromise.all
, meaning they all get loaded in parallel, which seems more like what you want. - It sounds like right now,
await
ingparseMtl
doesn't fully wait for it to be loaded. What does yourparseMtl
implementation look like right now? In the first comment in this chain, I had added some suggestions on how to updateparseMtl
to await for loading to complete, have you tried any of that out yet?
Let me know if I can help elaborate on any of those points!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@davepagurek below is the parseMtl function:
you had suggested using doneLoading and this is how I'm currently implementing the function:
function parseMtl(p5,mtlPath){ //accepts mtlPath to load file
return new Promise((resolve, reject)=>{
let currentMaterial = null;
let materials= {};
p5.loadStrings(
mtlPath,
lines => {
for (let line = 0; line < lines.length; ++line){
const tokens = lines[line].trim().split(/\s+/);
if(tokens[0] === 'newmtl') {
const materialName = tokens[1];
currentMaterial = materialName;
materials[currentMaterial] = {};
}else if (tokens[0] === 'Kd'){
//Diffuse color
materials[currentMaterial].diffuseColor = [
parseFloat(tokens[1]),
parseFloat(tokens[2]),
parseFloat(tokens[3])
];
} else if (tokens[0] === 'Ka'){
//Ambient Color
materials[currentMaterial].ambientColor = [
parseFloat(tokens[1]),
parseFloat(tokens[2]),
parseFloat(tokens[3])
];
}else if (tokens[0] === 'Ks'){
//Specular color
materials[currentMaterial].specularColor = [
parseFloat(tokens[1]),
parseFloat(tokens[2]),
parseFloat(tokens[3])
];
}else if (tokens[0] === 'map_Kd') {
//Texture path
materials[currentMaterial].texturePath = tokens[1];
}
}
resolve(materials);
},reject
);
});
}
…y/p5.js into Mtl-support-to-obj-files
src/webgl/loading.js
Outdated
@@ -312,8 +410,24 @@ function parseObj(model, lines) { | |||
model.vertexNormals.push(loadedVerts.vn[vertParts[2]].copy()); | |||
} | |||
} | |||
if (usedVerts[vertIndex] && usedVerts[vertIndex] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now I think we just store the last used material for each vertex. I think this means that if we reuse the same vertex first with material1, then with material2, then a third time with material1 again, we'll end up with three duplicates of the vertex instead of just two, since we make a new duplicate if the current material is not the same as the last used material.
Maybe instead of storing a map of source index → material, we can store a map from source index → another map of material → destination index. In that world, this if statement would check if usedVerts[vertIndex][currentMaterial]
is defined yet, and create it if not. (This would maybe have to also absorb the else
branch right above it maybe.) Then, below, we would add usedVerts[vertIndex][currentMaterial]
to the current face.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I missed out on how I could have optimized the logic here and thus have lesser duplication like in the example you mentioned above. Will work on it now. Thanks for pointing this out. @davepagurek
src/webgl/loading.js
Outdated
@@ -331,7 +455,7 @@ function parseObj(model, lines) { | |||
if (model.vertexNormals.length === 0) { | |||
model.computeNormals(); | |||
} | |||
|
|||
console.log(model.vertexColors); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume this is for debugging, although we should probably take this out before merging.
@davepagurek Made a few changes in the duplication of vertices and the number of vertices in the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice work on this! I think we're getting really close.
} | ||
const duplicatedVertIndex = model.vertices.length; | ||
|
||
if (usedVerts[vertParts[0]][currentMaterial] === undefined) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
src/webgl/loading.js
Outdated
materialDiffuseColor[1], | ||
materialDiffuseColor[2] | ||
]); | ||
if (currentMaterial && materials[currentMaterial]) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now this will break if we have some vertices that don't have a material but others that do (since it means the earlier vertices won't have pushed colors to the array.) Our data needs it to be all or nothing.
I don't think we need to actually support that case, but maybe we can try to detect it and throw an error if we encounter it? i.e. if we've added any vertices with no colors, and then find ourselves trying to add a vertex with color later on, throw an error letting people know they need colors for all their vertices or none.
visualTest('OBJ model with MTL file displays diffuse colors correctly', function(p5, screenshot) { | ||
return new Promise(resolve => { | ||
p5.createCanvas(50, 50, p5.WEBGL); | ||
p5.loadModel('unit/assets/octa-color.obj', model => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now the screenshot looks like just a dot. Maybe to make this clearer we can call model.normalize()
to normalize its size to, I think, 200px?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe we can also add one more test importing an object with no colors, and then drawing it with a colored fill()
to show that it can take on new colors.
@davepagurek visual test passes in the terminal but on localhost still shows some discrepancy. |
@davepagurek Let me know what you think, what I've tried to do is for eg: if there are 6 unique vertices at 6 coordinate positions and all of them have color at least once, the model is valid. If none of them has color, the model is valid. Otherwise, throw an error for inconsistency. |
Great work on this so far! I think we're almost done -- I left some comments about a possible simplification, and a test for the error case. The last thing I can think of is, how should we handle it if e.g. you export an obj + mtl from Blender but only upload the obj? Right now we throw an error, but maybe it would be better to log a warning without actually throwing an error, and then treat it like it just has no materials? (Would that just involve parsing the |
@davepagurek Have a look when you find the time. Let me know what else should be done, Thanks. |
src/webgl/loading.js
Outdated
} | ||
}catch (error) { | ||
console.error(error.message); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to console.error
here when we _friendlyError
below?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah yes! I thought of it but was only checking all errors in console while making this work. Sorry for the oversight while pushing. I'll make the changes.
src/webgl/loading.js
Outdated
} | ||
try { | ||
if(await fileExists(mtlPath)){ | ||
const parsedMaterialsIndividual = await parseMtl(self, mtlPath); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By adding await
here, we're waiting for each material to load one at a time rather than waiting for all of them to load in parallel at the Promise.all
below on line 195. Does this still work if we take out the await
on this line, given that we already wait for all of them later?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Removing await
here would result in parallel loading of all mtl filled and then await
with promise.all
which is more efficient in handling all files together , better than sequential parsing of each mtl file.
src/webgl/loading.js
Outdated
mtlPath = mtlFilename; | ||
} | ||
try { | ||
if(await fileExists(mtlPath)){ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This await
will also cause us to do these HEAD
requests one at a time instead of parallel. Maybe we can do something like:
parsedMaterialPromises.push(
fileExists(mtlPath).then(exists => {
if (exists) {
return parseMtl(self, mtlPath);
} else {
console.warn('MTL file not found or error in parsing; proceeding without materials.');
return {};
}
}).catch(error => {
console.warn('Error loading MTL file:', error);
return {};
})
);
(Also, while we're at it, maybe we can add the URL for the mtl file that we couldn't find to the warning we log?)
} | ||
async function fileExists(url) { | ||
try { | ||
const response = await fetch(url, { method: 'HEAD' }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!
@davepagurek Does this seem better? Thanks for the suggestions. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for your hard work on this and all the iterations you've made! I think this is good to go now!
Right now we're in the middle of releasing v1.9.1, and have a beta build that we're bug testing, so we're just merging in bug fixes until we finish the release. I've approved this PR though, so as soon as we finish the release, I'll merge this in!
@davepagurek Had a great time working on this, as a university student this PR improved my vanilla javascript concepts a lot. Thank you for putting in all the time and reviewing it so many times✨ |
Looks great! Thanks @diyaayay and @davepagurek! |
Resolves #6670
This PR is a Work in Progress.
Review is requested.
Changes:
Screenshots of the change:
The output after loading materials before the model is loaded and assigning diffuse colors to vertexColors
The output contains parsedMaterials and model.vertexColors
PR Checklist
npm run lint
passes