-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
GSOC 26: render multi-material models per part #8955
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
base: main
Are you sure you want to change the base?
Changes from all commits
bfec2d4
e812dae
962112d
b086d81
1234024
0101168
40d520a
ef33f8e
c9c0cc4
c7db707
e303901
c8f0ec2
aba7591
59b2cce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| */ | ||
|
|
||
| import { Geometry } from './p5.Geometry'; | ||
| import { GeometryPart, createPartState } from './p5.GeometryPart'; | ||
| import { Vector } from '../math/p5.Vector'; | ||
| import { request } from '../io/files'; | ||
|
|
||
|
|
@@ -17,6 +18,161 @@ async function fileExists(url) { | |
| } | ||
| } | ||
|
|
||
| // parse mtl text into a map of material name -> props. split from the file | ||
| // request so it's testable on its own. | ||
| function parseMtlData(data) { | ||
| let currentMaterial = null; | ||
| const materials = {}; | ||
| const lines = data.split('\n'); | ||
|
|
||
| for (let line = 0; line < lines.length; ++line) { | ||
| const tokens = lines[line].trim().split(/\s+/); | ||
| if (tokens[0] === 'newmtl') { | ||
| currentMaterial = tokens[1]; | ||
| materials[currentMaterial] = {}; | ||
| } else if (!currentMaterial) { | ||
| continue; | ||
| } 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] === 'Ns') { | ||
| //specular exponent (shininess) | ||
| materials[currentMaterial].shininess = parseFloat(tokens[1]); | ||
| } else if (tokens[0] === 'd') { | ||
| //dissolve, 1 is fully opaque | ||
| materials[currentMaterial].opacity = parseFloat(tokens[1]); | ||
| } else if (tokens[0] === 'Tr') { | ||
| //transparency, the inverse of d | ||
| materials[currentMaterial].opacity = 1 - parseFloat(tokens[1]); | ||
| } else if (tokens[0] === 'illum') { | ||
| //illumination model | ||
| materials[currentMaterial].illuminationModel = parseInt(tokens[1]); | ||
| } else if (tokens[0] === 'map_Kd') { | ||
| //diffuse texture | ||
| materials[currentMaterial].texturePath = tokens[1]; | ||
| } else if (tokens[0] === 'map_Ka') { | ||
| //ambient texture | ||
| materials[currentMaterial].ambientTexturePath = tokens[1]; | ||
| } else if (tokens[0] === 'map_Ks') { | ||
| //specular texture | ||
| materials[currentMaterial].specularTexturePath = tokens[1]; | ||
| } else if (tokens[0] === 'map_Bump' || tokens[0] === 'bump') { | ||
| //bump map. -bm etc can precede the path so take the last token. parsed | ||
| //but not used until the renderer handles it. | ||
| materials[currentMaterial].bumpTexturePath = tokens[tokens.length - 1]; | ||
| } | ||
| } | ||
|
|
||
| return materials; | ||
| } | ||
|
|
||
| // mtl material -> part state in p5's vocab. anything we can't draw yet is left | ||
| // off until support lands. | ||
| function mtlToPartState(material) { | ||
| const state = createPartState(); | ||
| if (!material) return state; | ||
| if (material.diffuseColor) { | ||
| // carry the mtl transparency (d / Tr) in the fill's alpha. opaque materials | ||
| // stay rgb so nothing changes for them. | ||
| state.fill = material.opacity != null && material.opacity < 1 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might simplify things further to have the fill always be in the same format. So you could do |
||
| ? [...material.diffuseColor, material.opacity] | ||
| : material.diffuseColor; | ||
| } | ||
| if (material.ambientColor) state.ambientColor = material.ambientColor; | ||
| if (material.specularColor) state.specularColor = material.specularColor; | ||
| if (material.shininess !== undefined) state.shininess = material.shininess; | ||
| if (material.texture) state.texture = material.texture; | ||
| return state; | ||
| } | ||
|
|
||
| // load each material's diffuse texture (map_Kd) and hang it on the material so | ||
| // it lands on the part state. paths resolve relative to the model file, a | ||
| // texture that fails just gets skipped. no-op if there's no loadImage. only | ||
| // map_Kd for now since that's all the renderer can use. | ||
| async function loadMaterialTextures(materials, modelPath, instance) { | ||
| if (!instance || typeof instance.loadImage !== 'function') return; | ||
|
|
||
| const slash = modelPath.lastIndexOf('/'); | ||
| const folder = slash >= 0 ? modelPath.slice(0, slash) : ''; | ||
| const resolve = file => (folder ? `${folder}/${file}` : file); | ||
|
|
||
| const jobs = []; | ||
| for (const name in materials) { | ||
| const material = materials[name]; | ||
| if (!material.texturePath) continue; | ||
| const url = resolve(material.texturePath); | ||
| jobs.push( | ||
| instance.loadImage(url) | ||
| .then(img => { | ||
| material.texture = img; | ||
| }) | ||
| .catch(() => { | ||
| console.warn(`Texture not found, skipping: ${url}`); | ||
| }) | ||
| ); | ||
| } | ||
|
|
||
| await Promise.all(jobs); | ||
| } | ||
|
|
||
| // split the model's faces into one part per material. the combined arrays stay | ||
| // as the aggregate; each part gets its own localised verts with faces re-indexed | ||
| // against them, plus its material's state. | ||
| function buildMaterialParts(model, faceMaterials, materials) { | ||
| // only split when there are genuinely multiple materials. a single material | ||
| // (or none) stays as the geometry's own part and renders as before. one group | ||
| // per material, plus a null group for faces before any usemtl so none drop. | ||
| const names = [...new Set(faceMaterials)]; | ||
| if (names.filter(name => name != null).length < 2) return; | ||
|
|
||
| const hasUvs = model.uvs.length > 0; | ||
| const hasNormals = model.vertexNormals.length > 0; | ||
| const parts = []; | ||
|
|
||
| for (const name of names) { | ||
| const part = new GeometryPart( | ||
| `${model.gid}|part${parts.length}`, | ||
| mtlToPartState(materials[name]) | ||
| ); | ||
| // global vertex index -> this part's local index, added on first use | ||
| const localIndex = new Map(); | ||
| for (let fi = 0; fi < model.faces.length; fi++) { | ||
| if (faceMaterials[fi] !== name) continue; | ||
| const localFace = model.faces[fi].map(vi => { | ||
| if (!localIndex.has(vi)) { | ||
| localIndex.set(vi, part.vertices.length); | ||
| part.vertices.push(model.vertices[vi]); | ||
| if (hasUvs) part.uvs.push(model.uvs[vi]); | ||
| if (hasNormals) part.vertexNormals.push(model.vertexNormals[vi]); | ||
| } | ||
| return localIndex.get(vi); | ||
| }); | ||
| part.faces.push(localFace); | ||
| } | ||
| parts.push(part); | ||
| } | ||
|
|
||
| model.parts = parts; | ||
| } | ||
|
|
||
| function loading(p5, fn){ | ||
| /** | ||
| * Loads a 3D model to create a | ||
|
|
@@ -446,6 +602,7 @@ function loading(p5, fn){ | |
| const lines = data.split('\n'); | ||
|
|
||
| const parsedMaterials = await getMaterials(lines); | ||
| await loadMaterialTextures(parsedMaterials, path, this); | ||
| const cb = () => { | ||
| parseObj(model, lines, parsedMaterials); | ||
|
|
||
|
|
@@ -482,47 +639,8 @@ function loading(p5, fn){ | |
| * @private | ||
| */ | ||
| async function parseMtl(mtlPath) { | ||
| let currentMaterial = null; | ||
| let materials = {}; | ||
|
|
||
| const { data } = await request(mtlPath, 'text'); | ||
| const lines = data.split('\n'); | ||
|
|
||
| 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]; | ||
| } | ||
| } | ||
|
|
||
| return materials; | ||
| return parseMtlData(data); | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -557,6 +675,8 @@ function loading(p5, fn){ | |
| // Map from source index → Map of material → destination index | ||
| const usedVerts = {}; // Track colored vertices | ||
| let currentMaterial = null; | ||
| // material per kept face, aligned with model.faces, for bucketing later | ||
| const faceMaterials = []; | ||
| let hasColoredVertices = false; | ||
| let hasColorlessVertices = false; | ||
| for (let line = 0; line < lines.length; ++line) { | ||
|
|
@@ -642,6 +762,7 @@ function loading(p5, fn){ | |
| face[1] !== face[2] | ||
| ) { | ||
| model.faces.push(face); | ||
| faceMaterials.push(currentMaterial); | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -655,6 +776,9 @@ function loading(p5, fn){ | |
| model.vertexColors = []; | ||
| } | ||
|
|
||
| // bucket faces into per-material parts (aggregate arrays above stay as-is) | ||
| buildMaterialParts(model, faceMaterials, materials); | ||
|
|
||
| return model; | ||
| } | ||
|
|
||
|
|
@@ -1296,6 +1420,7 @@ function loading(p5, fn){ | |
| } | ||
|
|
||
| export default loading; | ||
| export { parseMtlData, mtlToPartState, buildMaterialParts }; | ||
|
|
||
| if(typeof p5 !== 'undefined'){ | ||
| loading(p5, p5.prototype); | ||
|
|
||
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.
Heads up,
!=will consider 0 to be equivalent tonullas both are falsy. Do you want!==here? (Also worth double checking that one of these could have valid falsy values -- to confirm, arefillandambientColorp5.Color values or arrays?)