Skip to content
Merged
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
50 changes: 49 additions & 1 deletion src/core/p5.Renderer3D.js
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,29 @@ export class Renderer3D extends Renderer {
geometry.vertices.length >= 3 &&
![constants.LINES, constants.POINTS].includes(mode)
) {
this._drawFills(geometry, { mode, count });
// draw every part. a geometry always has at least one part (the
// constructor makes it its own single part when nothing else does), so no
// fallback is needed. a part with no material state draws straight;
// multi-material parts apply their own material around the draw.
for (const part of geometry.parts) {
const state = part.partState;
// fill/ambientColor/specularColor are arrays and texture is a p5.Image,
// so they're truthy when set; shininess is a number where 0 is valid.
// `!= null` keeps every field consistent and treats a 0 as present.
const hasMaterial = state && (
state.fill != null || state.texture != null ||
state.ambientColor != null || state.specularColor != null ||
state.shininess != null
);
if (hasMaterial) {
this.push();
this._applyPartState(state);
this._drawFills(part, { mode, count });
this.pop();
} else {
this._drawFills(part, { mode, count });
}
}
}

if (this.states.strokeColor && geometry.lineVertices.length >= 1) {
Expand Down Expand Up @@ -631,6 +653,32 @@ export class Renderer3D extends Renderer {
shader.unbindShader();
}

// apply a part's material to the renderer before it's drawn. only non-null
// fields are set, so an empty part state leaves the uniforms untouched.
_applyPartState(partState) {
if (!partState) return;
if (partState.fill) {
// fill is always [r, g, b, a] in 0..1, so it maps straight onto
// curFillColor (same shape/range as the array fill() sets).
this.states.setValue('curFillColor', partState.fill);
}
if (partState.texture) {
this.states.setValue('_tex', partState.texture);
this.states.setValue('drawMode', constants.TEXTURE);
}
if (partState.ambientColor) {
this.states.setValue('curAmbientColor', partState.ambientColor);
this.states.setValue('_hasSetAmbient', true);
}
if (partState.specularColor) {
this.states.setValue('curSpecularColor', partState.specularColor);
this.states.setValue('_useSpecularMaterial', true);
}
if (partState.shininess != null) {
this.states.setValue('_useShininess', partState.shininess);
}
}

_drawStrokes(geometry, { count } = {}) {

this._useLineColor = geometry.vertexStrokeColors.length > 0;
Expand Down
2 changes: 0 additions & 2 deletions src/webgl/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ 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 @@ -29,7 +28,6 @@ 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
13 changes: 9 additions & 4 deletions src/webgl/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,11 @@ function parseMtlData(data) {
function mtlToPartState(material) {
const state = createPartState();
if (!material) return state;
if (material.diffuseColor) state.fill = material.diffuseColor;
if (material.diffuseColor) {
// fill is always [r, g, b, a] in 0..1. the mtl d/Tr becomes the alpha, and
// opaque materials default to 1 so the format stays consistent either way.
state.fill = [...material.diffuseColor, material.opacity ?? 1];
}
if (material.ambientColor) state.ambientColor = material.ambientColor;
if (material.specularColor) state.specularColor = material.specularColor;
if (material.shininess !== undefined) state.shininess = material.shininess;
Expand Down Expand Up @@ -131,10 +135,11 @@ async function loadMaterialTextures(materials, modelPath, instance) {
// 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.
// 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.some(name => name != null)) return;
if (names.filter(name => name != null).length < 2) return;

const hasUvs = model.uvs.length > 0;
const hasNormals = model.vertexNormals.length > 0;
Expand Down
41 changes: 11 additions & 30 deletions src/webgl/p5.Geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

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

Expand Down Expand Up @@ -65,9 +65,13 @@ class Geometry {
this.gid = `_p5_Geometry_${Geometry.nextId}`;
Geometry.nextId++;

// a geometry can act as its own single part: this is the default material
// state used when it is drawn that way (see _wrapInSinglePart).
this.partState = createPartState();

// 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.
// becomes its own single part below.
this.parts = [];

if (callback instanceof Function) {
Expand All @@ -79,35 +83,11 @@ class Geometry {
}
}

// 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.
// a geometry with no material breakdown is its own single part. using the
// geometry itself (rather than a wrapper) means drawing the part is exactly
// drawing the geometry: same buffers, gid, dirty flags and custom attributes.
_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];
this.parts = [this];
}

/**
Expand Down Expand Up @@ -2142,6 +2122,7 @@ function geometry(p5, fn){
* }
*/
p5.Geometry = Geometry;
p5.GeometryPart = GeometryPart;

/**
* An array with the geometry's vertices.
Expand Down
35 changes: 21 additions & 14 deletions src/webgl/p5.GeometryPart.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@
*/

// 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.
// importers translate into this and drop anything we can't draw yet. every color
// channel is 0..1 (same range as the renderer's curFillColor), not 0..255.
function createPartState() {
return {
fill: null, // Kd
ambientColor: null, // Ka
specularColor: null, // Ks
shininess: null, // Ns
texture: null // map_Kd
fill: null, // Kd + d -> [r, g, b, a] | null, each 0..1

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.

Nice!

ambientColor: null, // Ka -> [r, g, b] | null, each 0..1
specularColor: null, // Ks -> [r, g, b] | null, each 0..1
shininess: null, // Ns -> number | null
texture: null // map_Kd -> p5.Image | null
};
}

Expand All @@ -32,16 +33,22 @@ class GeometryPart {

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

// custom per-vertex attributes (p5.strands). empty for parsed parts; the
// single-part wrap points this back at the parent geometry.
this.userVertexProperties = {};
}
}

function geometryPart(p5, fn) {
p5.GeometryPart = GeometryPart;
// the renderer needs this to pick a blend mode. a part is transparent if its
// fill has alpha below 1, or any of its vertex colors does.
hasFillTransparency() {
const fill = this.partState && this.partState.fill;
if (fill && fill[3] < 1) return true;
for (let i = 3; i < this.vertexColors.length; i += 4) {
if (this.vertexColors[i] < 1) return true;
}
return false;
}
}

export default geometryPart;
export { GeometryPart, createPartState };

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

newmtl mat1
Kd 1 0 0
4 changes: 4 additions & 0 deletions test/unit/assets/textured.obj
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ mtllib textured.mtl
v 0 0 0
v 1 0 0
v 0 1 0
v 1 1 0
vt 0 0
vt 1 0
vt 0 1
vt 1 1
usemtl mat0
f 1/1 2/2 3/3
usemtl mat1
f 2/2 4/4 3/3
18 changes: 10 additions & 8 deletions test/unit/io/loadModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ suite('loadModel', function() {
const totalFaces = model.parts.reduce((sum, p) => sum + p.faces.length, 0);
assert.equal(totalFaces, model.faces.length);

// first material (m000001) is Kd 0 0 0.5 -> part fill
assert.deepEqual(model.parts[0].partState.fill, [0, 0, 0.5]);
// first material (m000001) is Kd 0 0 0.5 -> part fill, opaque alpha
assert.deepEqual(model.parts[0].partState.fill, [0, 0, 0.5, 1]);
assert.equal(model.parts[0].partState.shininess, 100);

// faces re-indexed against each part's own localised verts
Expand All @@ -112,10 +112,12 @@ suite('loadModel', function() {
};
try {
const model = await mockP5Prototype.loadModel('/test/unit/assets/textured.obj');
// single material -> one part carrying that material's state
assert.equal(model.parts.length, 1);
assert.equal(model.parts[0].partState.texture, fakeImage);
assert.equal(model.parts[0].partState.shininess, 50);
// two materials, so two parts; the textured one carries the image.
assert.equal(model.parts.length, 2);
const textured = model.parts.find(p => p.partState.texture);
assert.ok(textured, 'a part has the loaded texture');
assert.equal(textured.partState.texture, fakeImage);
assert.equal(textured.partState.shininess, 50);
} finally {
delete mockP5Prototype.loadImage;
}
Expand All @@ -127,8 +129,8 @@ suite('loadModel', function() {
};
try {
const model = await mockP5Prototype.loadModel('/test/unit/assets/textured.obj');
assert.equal(model.parts.length, 1);
assert.equal(model.parts[0].partState.texture, null);
assert.equal(model.parts.length, 2);
assert.ok(model.parts.every(p => p.partState.texture == null));
} finally {
delete mockP5Prototype.loadImage;
}
Expand Down
2 changes: 1 addition & 1 deletion test/unit/io/parseMtl.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ suite('mtlToPartState', function() {
specularColor: [0, 0, 1],
shininess: 32
});
expect(state.fill).toEqual([1, 0, 0]);
expect(state.fill).toEqual([1, 0, 0, 1]);
expect(state.ambientColor).toEqual([0, 1, 0]);
expect(state.specularColor).toEqual([0, 0, 1]);
expect(state.shininess).toEqual(32);
Expand Down
30 changes: 28 additions & 2 deletions test/unit/webgl/p5.GeometryPart.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import p5 from '../../../src/app.js';
import { vi } from 'vitest';

suite('p5.GeometryPart', function() {
let myp5;
Expand Down Expand Up @@ -70,11 +71,12 @@ suite('p5.GeometryPart', function() {
expect(geom.parts[0].faces).toBe(geom.faces);
});

test('the part gid tracks the geometry gid after it changes', function() {
test('a single-material geometry is its own part', function() {
const geom = new p5.Geometry(undefined, undefined, undefined,
myp5._renderer);
geom.gid = 'my-model';
expect(geom.parts[0].gid).toEqual('my-model|part0');
expect(geom.parts[0]).toBe(geom);
expect(geom.parts[0].gid).toEqual('my-model');
});

test('a built-in primitive also gets one part', function() {
Expand All @@ -85,4 +87,28 @@ suite('p5.GeometryPart', function() {
expect(geom.parts[0].vertices.length).toEqual(1);
});
});

suite('multi-material rendering', function() {
test('draws a fill per part and a stroke for the model', async function() {
const renderer = myp5.createCanvas(50, 50, myp5.WEBGL);
const model = await myp5.loadModel('/test/unit/assets/octa-color.obj');

// octa-color has several materials, so several parts.
expect(model.parts.length).toBeGreaterThan(1);

const fillSpy = vi.spyOn(renderer, '_drawFills');
const strokeSpy = vi.spyOn(renderer, '_drawStrokes');
myp5.background(255);
myp5.stroke(0);
myp5.model(model);

// one fill draw per material part.
expect(fillSpy).toHaveBeenCalledTimes(model.parts.length);
// the model's outline is still stroked.
expect(strokeSpy).toHaveBeenCalled();

fillSpy.mockRestore();
strokeSpy.mockRestore();
});
});
});
Loading