Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/webgl/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import renderBuffer from './p5.RenderBuffer';
import quat from './p5.Quat';
import matrix from '../math/p5.Matrix';
import geometry from './p5.Geometry';
import geometryPart from './p5.GeometryPart';
import framebuffer from './p5.Framebuffer';
import dataArray from './p5.DataArray';
import camera from './p5.Camera';
Expand All @@ -28,6 +29,7 @@ export default function(p5){
p5.registerAddon(quat);
p5.registerAddon(matrix);
p5.registerAddon(geometry);
p5.registerAddon(geometryPart);
p5.registerAddon(camera);
p5.registerAddon(framebuffer);
p5.registerAddon(dataArray);
Expand Down
198 changes: 158 additions & 40 deletions src/webgl/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -17,6 +18,154 @@ 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) state.fill = 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) {
// one group per material, plus a null group for faces before any usemtl so
// none get dropped. no materials at all -> keep the default wrap.
const names = [...new Set(faceMaterials)];
if (!names.some(name => name != null)) 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
Expand Down Expand Up @@ -446,6 +595,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);

Expand Down Expand Up @@ -482,47 +632,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);
}

/**
Expand Down Expand Up @@ -557,6 +668,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) {
Expand Down Expand Up @@ -642,6 +755,7 @@ function loading(p5, fn){
face[1] !== face[2]
) {
model.faces.push(face);
faceMaterials.push(currentMaterial);
}
}
}
Expand All @@ -655,6 +769,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;
}

Expand Down Expand Up @@ -1296,6 +1413,7 @@ function loading(p5, fn){
}

export default loading;
export { parseMtlData, mtlToPartState, buildMaterialParts };

if(typeof p5 !== 'undefined'){
loading(p5, p5.prototype);
Expand Down
42 changes: 42 additions & 0 deletions src/webgl/p5.Geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import * as constants from '../core/constants';
import { DataArray } from './p5.DataArray';
import { GeometryPart } from './p5.GeometryPart';
import { Vector } from '../math/p5.Vector';
import { downloadFile } from '../io/utilities';

Expand Down Expand Up @@ -63,9 +64,50 @@ class Geometry {

this.gid = `_p5_Geometry_${Geometry.nextId}`;
Geometry.nextId++;

// every geometry is one or more parts (see p5.GeometryPart). loaders that
// know about materials fill parts themselves; anything built the old way
// gets wrapped in a single part below.
this.parts = [];

if (callback instanceof Function) {
callback.call(this);
}

if (this.parts.length === 0) {
this._wrapInSinglePart();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe double check, but I assume that since this check is in the constructor, geometry built via buildGeometry will hit this path too and end up having its vertices mapped to a single part.

One thought: maybe we can default to having one part if none have been made yet, and that's where the data lives, and on the geometry itself we just have getter/setters that map to that first part? If we mark it as empty with a flag, then a method to add a part can check for that first and delete the old one if it's the default empty one?

Regardless, we maybe want to make parts a documented thing (so, maybe without the _ prefix on the name) and mark the direct access of vertices.faces/etc deprecated so that it's still there for now, but the expectation is that it's removed in 3.0?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @davepagurek for the detailed thoughts here, really helpful

checked the buildGeometry path, you're right, it goes through the same Geometry constructor so it gets wrapped into one part too. works fine, tests pass, good to confirm

i like the first-part-holds-the-data idea. in the renderer follow up i just had the geometry be its own first part (parts = [this]) so there's only one copy, but happy to do the getter + empty-flag way if you prefer it

and agreed on making parts public, renamed it already, and i'll mark direct vertices/faces access deprecated so it keeps working for now but is set to go in 3.0

}
}

// wrap this geometry's own buffers in one part, for anything built the old way
// (primitives, new p5.Geometry(cb)). the part is a live view onto our arrays,
// not a copy, so reassigning an array or changing gid later can't desync it.
_wrapInSinglePart() {
const geometry = this;
const part = new GeometryPart(`${this.gid}|part0`);
for (const field of [
'vertices',
'vertexNormals',
'faces',
'uvs',
'vertexColors'
]) {
Object.defineProperty(part, field, {
get() {
return geometry[field];
},
enumerable: true,
configurable: true
});
}
Object.defineProperty(part, 'gid', {
get() {
return `${geometry.gid}|part0`;
},
enumerable: true,
configurable: true
});
this.parts = [part];
}

/**
Expand Down
47 changes: 47 additions & 0 deletions src/webgl/p5.GeometryPart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @module Shape
* @submodule 3D Primitives
* @for p5
*/

// fresh part state. fields use p5 names (fill, texture...), not obj/mtl tokens.
// importers translate into this and drop anything we can't draw yet.
function createPartState() {
return {
fill: null, // Kd
ambientColor: null, // Ka
specularColor: null, // Ks
shininess: null, // Ns
texture: null // map_Kd
};
}

// one part of a geometry. a multi-material model is a p5.Geometry made of
// several parts, each holding the verts/faces/uvs for one material plus the
// state to draw them. single-material models are just one part.
class GeometryPart {
constructor(gid, partState) {
// renderer caches buffers by this, derived from the parent geometry's gid
this.gid = gid;

this.vertices = [];
this.vertexNormals = [];
this.faces = [];
this.uvs = [];
this.vertexColors = [];

this.partState = partState || createPartState();
this.dirtyFlags = {};
}
}

function geometryPart(p5, fn) {
p5.GeometryPart = GeometryPart;
}

export default geometryPart;
export { GeometryPart, createPartState };

if (typeof p5 !== 'undefined') {
geometryPart(p5, p5.prototype);
}
4 changes: 4 additions & 0 deletions test/unit/assets/textured.mtl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
newmtl mat0
Kd 1 1 1
Ns 50
map_Kd cat.jpg
9 changes: 9 additions & 0 deletions test/unit/assets/textured.obj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mtllib textured.mtl
v 0 0 0
v 1 0 0
v 0 1 0
vt 0 0
vt 1 0
vt 0 1
usemtl mat0
f 1/1 2/2 3/3
Loading
Loading