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
53 changes: 51 additions & 2 deletions src/core/p5.Renderer3D.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,10 @@ export class Renderer3D extends Renderer {
this.states.drawMode = constants.FILL;

this.states._tex = null;
this.states._specularTex = null;
this.states._ambientTex = null;
this.states._shininessTex = null;
this.states._normalTex = null;
this.states.textureMode = constants.IMAGE;
this.states.textureWrapX = constants.CLAMP;
this.states.textureWrapY = constants.CLAMP;
Expand Down Expand Up @@ -292,7 +296,17 @@ export class Renderer3D extends Renderer {
),
new RenderBuffer(2, 'uvs', 'uvBuffer', 'aTexCoord', this, arr =>
arr.flat()
)
),
// surface tangents for normal mapping. [x, y, z, handedness] per vertex;
// defaults to a dummy tangent when a model has none so the attribute is
// always valid (the shader only uses it when a normal map is bound).
new RenderBuffer(
4,
'vertexTangents',
'tangentBuffer',
'aTangent',
this
).default(geometry => geometry.vertices.flatMap(() => [0, 0, 0, 1]))
],
stroke: [
new RenderBuffer(
Expand Down Expand Up @@ -611,7 +625,10 @@ export class Renderer3D extends Renderer {
state.texture != null ||
state.ambientColor != null ||
state.specularColor != null ||
state.shininess != null);
state.shininess != null ||
state.specularTexture != null ||
state.ambientTexture != null ||
state.shininessTexture != null);
if (hasMaterial) {
this.push();
this._applyPartState(state);
Expand Down Expand Up @@ -676,13 +693,29 @@ export class Renderer3D extends Renderer {
this.states.setValue('curAmbientColor', partState.ambientColor);
this.states.setValue('_hasSetAmbient', true);
}
if (partState.ambientTexture) {
// an ambient map modulates the ambient term, so make sure it is on
this.states.setValue('_ambientTex', partState.ambientTexture);
this.states.setValue('_hasSetAmbient', true);
}
if (partState.specularColor) {
this.states.setValue('curSpecularColor', partState.specularColor);
this.states.setValue('_useSpecularMaterial', true);
}
if (partState.specularTexture) {
// a specular map modulates the specular term, so make sure that term is on
this.states.setValue('_specularTex', partState.specularTexture);
this.states.setValue('_useSpecularMaterial', true);
}
if (partState.shininess != null) {
this.states.setValue('_useShininess', partState.shininess);
}
if (partState.shininessTexture) {
this.states.setValue('_shininessTex', partState.shininessTexture);
}
if (partState.normalTexture) {
this.states.setValue('_normalTex', partState.normalTexture);
}
}

_drawStrokes(geometry, { count } = {}) {
Expand Down Expand Up @@ -1567,6 +1600,22 @@ export class Renderer3D extends Renderer {
fillShader.setUniform('uSampler', this.states._tex || empty);
}
this._settingFillUniforms = false;
// specular map (map_Ks): always bind so the sampler is valid; the bool gates
// whether the shader actually uses it, so untextured draws are unaffected.
fillShader.setUniform('uHasSpecularTex', !!this.states._specularTex);
fillShader.setUniform('uSpecularSampler', this.states._specularTex || empty);
// ambient map (map_Ka): same always-bind + bool-gate pattern
fillShader.setUniform('uHasAmbientTex', !!this.states._ambientTex);
fillShader.setUniform('uAmbientSampler', this.states._ambientTex || empty);
// shininess map (map_Ns): scales the base shininess by the map's red channel
fillShader.setUniform('uHasShininessTex', !!this.states._shininessTex);
fillShader.setUniform(
'uShininessSampler',
this.states._shininessTex || empty
);
// normal map (map_Bump): perturbs the surface normal in tangent space
fillShader.setUniform('uHasNormalMap', !!this.states._normalTex);
fillShader.setUniform('uNormalSampler', this.states._normalTex || empty);
fillShader.setUniform(
'uTint',
this.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255]
Expand Down
81 changes: 65 additions & 16 deletions src/webgl/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ function parseMtlData(data) {
} else if (tokens[0] === 'map_Ks') {
//specular texture
materials[currentMaterial].specularTexturePath = tokens[1];
} else if (tokens[0] === 'map_Ns') {
//shininess texture
materials[currentMaterial].shininessTexturePath = 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.
Expand All @@ -98,13 +101,39 @@ function mtlToPartState(material) {
if (material.specularColor) state.specularColor = material.specularColor;
if (material.shininess !== undefined) state.shininess = material.shininess;
if (material.texture) state.texture = material.texture;
if (material.specularTexture) {
state.specularTexture = material.specularTexture;
// a specular map modulates a base specular colour; default to white so the
// map shows even when the mtl has a map_Ks but no explicit Ks colour.
if (!state.specularColor) state.specularColor = [1, 1, 1];
}
if (material.ambientTexture) {
state.ambientTexture = material.ambientTexture;
// same idea as the specular map: default the base ambient colour to white
if (!state.ambientColor) state.ambientColor = [1, 1, 1];
}
if (material.shininessTexture) {
state.shininessTexture = material.shininessTexture;
// the map scales the base shininess; default the base to 1 when no Ns
if (state.shininess == null) state.shininess = 1;
}
if (material.normalTexture) state.normalTexture = material.normalTexture;
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.
// each texture map the renderer can use: the parsed path field on the material,
// and the image field we hang the loaded p5.Image on for mtlToPartState to read.
const MATERIAL_TEXTURE_MAPS = [
['texturePath', 'texture'], // map_Kd (diffuse)
['specularTexturePath', 'specularTexture'], // map_Ks (specular)
['ambientTexturePath', 'ambientTexture'], // map_Ka (ambient)
['shininessTexturePath', 'shininessTexture'], // map_Ns (shininess)
['bumpTexturePath', 'normalTexture'] // map_Bump (normal)
];

// load each material's texture maps and hang them on the material so they land
// 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.
async function loadMaterialTextures(materials, modelPath, instance) {
if (!instance || typeof instance.loadImage !== 'function') return;

Expand All @@ -115,18 +144,20 @@ async function loadMaterialTextures(materials, modelPath, instance) {
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}`);
})
);
for (const [pathField, imageField] of MATERIAL_TEXTURE_MAPS) {
if (!material[pathField]) continue;
const url = resolve(material[pathField]);
jobs.push(
instance
.loadImage(url)
.then(img => {
material[imageField] = img;
})
.catch(() => {
console.warn(`Texture not found, skipping: ${url}`);
})
);
}
}

await Promise.all(jobs);
Expand All @@ -144,6 +175,7 @@ function buildMaterialParts(model, faceMaterials, materials) {

const hasUvs = model.uvs.length > 0;
const hasNormals = model.vertexNormals.length > 0;
const hasTangents = model.vertexTangents.length > 0;
const parts = [];

for (const name of names) {
Expand All @@ -161,6 +193,14 @@ function buildMaterialParts(model, faceMaterials, materials) {
part.vertices.push(model.vertices[vi]);
if (hasUvs) part.uvs.push(model.uvs[vi]);
if (hasNormals) part.vertexNormals.push(model.vertexNormals[vi]);
if (hasTangents) {
part.vertexTangents.push(
model.vertexTangents[vi * 4],
model.vertexTangents[vi * 4 + 1],
model.vertexTangents[vi * 4 + 2],
model.vertexTangents[vi * 4 + 3]
);
}
}
return localIndex.get(vi);
});
Expand Down Expand Up @@ -784,6 +824,15 @@ function loading(p5, fn) {
model.vertexColors = [];
}

// normal maps need per-vertex tangents; compute them once on the aggregate
// (normals are ready above) so buildMaterialParts hands each part its slice.
const needsTangents = Object.values(materials).some(
m => m && m.normalTexture
);
if (needsTangents) {
model.computeTangents();
}

// bucket faces into per-material parts (aggregate arrays above stay as-is)
buildMaterialParts(model, faceMaterials, materials);

Expand Down
79 changes: 79 additions & 0 deletions src/webgl/p5.Geometry.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class Geometry {

this.vertexNormals = [];

// per-vertex surface tangents for normal mapping, stored flat as
// [x, y, z, w] where w is the bitangent handedness. computeTangents() fills
// this; empty until a normal-mapped model needs it.
this.vertexTangents = [];

this.faces = [];

this.uvs = [];
Expand Down Expand Up @@ -250,6 +255,7 @@ class Geometry {
this.vertexStrokeColors.length = 0;
this.lineVertexColors.clear();
this.vertexNormals.length = 0;
this.vertexTangents.length = 0;
this.uvs.length = 0;

for (const propName in this.userVertexProperties) {
Expand Down Expand Up @@ -1256,6 +1262,79 @@ class Geometry {
return this;
}

/**
* computes a per-vertex surface tangent from the uvs, needed for normal
* (bump) mapping. the tangent points along the +u texture direction; its w
* component stores the bitangent handedness so the shader can rebuild the
* bitangent as cross(normal, tangent) * w. results are stored flat as
* [x, y, z, w] per vertex on this.vertexTangents. needs uvs and vertex
* normals, so run computeNormals() first if the model has none.
* @private
* @chainable
*/
computeTangents() {
const vertices = this.vertices;
const faces = this.faces;
const uvs = this.uvs.flat();
const normals = this.vertexNormals;

// nothing to build a tangent basis from without uvs and normals
if (uvs.length === 0 || normals.length === 0) {
this.vertexTangents = [];
return this;
}

// accumulate the +u direction (tan) and +v direction (bitan) per vertex
const tan = [];
const bitan = [];
for (let i = 0; i < vertices.length; i++) {
tan.push(new Vector(0, 0, 0));
bitan.push(new Vector(0, 0, 0));
}
const uvAt = i => ({ x: uvs[i * 2] || 0, y: uvs[i * 2 + 1] || 0 });

for (const face of faces) {
const [i0, i1, i2] = face;
const e1 = Vector.sub(vertices[i1], vertices[i0]);
const e2 = Vector.sub(vertices[i2], vertices[i0]);
const w0 = uvAt(i0);
const w1 = uvAt(i1);
const w2 = uvAt(i2);
const du1 = w1.x - w0.x;
const dv1 = w1.y - w0.y;
const du2 = w2.x - w0.x;
const dv2 = w2.y - w0.y;

const denom = du1 * dv2 - du2 * dv1;
const r = denom === 0 ? 0 : 1 / denom;
const sdir = Vector.sub(Vector.mult(e1, dv2), Vector.mult(e2, dv1)).mult(r);
const tdir = Vector.sub(Vector.mult(e2, du1), Vector.mult(e1, du2)).mult(r);

for (const idx of face) {
tan[idx].add(sdir);
bitan[idx].add(tdir);
}
}

// orthonormalise each tangent against its normal and record handedness
const tangents = [];
for (let i = 0; i < vertices.length; i++) {
const n = normals[i] || new Vector(0, 0, 1);
let t = Vector.sub(tan[i], Vector.mult(n, n.dot(tan[i])));
if (t.magSq() === 0) {
// degenerate uvs: pick any direction perpendicular to the normal
const seed = Math.abs(n.x) < 0.9 ? new Vector(1, 0, 0) : new Vector(0, 1, 0);
t = Vector.sub(seed, Vector.mult(n, n.dot(seed)));
}
t.normalize();
const handedness = Vector.cross(n, t).dot(bitan[i]) < 0 ? -1 : 1;
tangents.push(t.x, t.y, t.z, handedness);
}

this.vertexTangents = tangents;
return this;
}

/**
* Averages the vertex normals. Used in curved
* surfaces
Expand Down
8 changes: 7 additions & 1 deletion src/webgl/p5.GeometryPart.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ function createPartState() {
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
texture: null, // map_Kd -> p5.Image | null
specularTexture: null, // map_Ks -> p5.Image | null
ambientTexture: null, // map_Ka -> p5.Image | null
shininessTexture: null, // map_Ns -> p5.Image | null
normalTexture: null // map_Bump -> p5.Image | null
};
}

Expand All @@ -27,6 +31,8 @@ class GeometryPart {

this.vertices = [];
this.vertexNormals = [];
// surface tangents for normal mapping, flat [x, y, z, w] per vertex
this.vertexTangents = [];
this.faces = [];
this.uvs = [];
this.vertexColors = [];
Expand Down
Loading
Loading