From bc374f1d31e463eb327355a573a9eeec97e37e3c Mon Sep 17 00:00:00 2001 From: nityam Date: Sun, 9 Aug 2026 23:50:11 +0530 Subject: [PATCH 1/8] render map_Ks specular textures per part --- src/core/p5.Renderer3D.js | 10 +++++++ src/webgl/loading.js | 46 +++++++++++++++++++----------- src/webgl/p5.GeometryPart.js | 3 +- src/webgl/shaders/phong.frag | 6 +++- test/unit/io/parseMtl.js | 19 ++++++++++++ test/unit/webgl/p5.GeometryPart.js | 3 +- 6 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 455ec6d59c..7804508eb1 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -148,6 +148,7 @@ export class Renderer3D extends Renderer { this.states.drawMode = constants.FILL; this.states._tex = null; + this.states._specularTex = null; this.states.textureMode = constants.IMAGE; this.states.textureWrapX = constants.CLAMP; this.states.textureWrapY = constants.CLAMP; @@ -680,6 +681,11 @@ export class Renderer3D extends Renderer { 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); } @@ -1567,6 +1573,10 @@ 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); fillShader.setUniform( 'uTint', this.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255] diff --git a/src/webgl/loading.js b/src/webgl/loading.js index f9a546f03b..fc48ca9ac7 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -98,13 +98,25 @@ 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]; + } 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) +]; + +// 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; @@ -115,18 +127,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); diff --git a/src/webgl/p5.GeometryPart.js b/src/webgl/p5.GeometryPart.js index 7afa6e152a..09c685ac30 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -13,7 +13,8 @@ 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 }; } diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 47ec519d47..a02e40ca0d 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -10,6 +10,8 @@ uniform vec4 uEmissiveMatColor; uniform vec4 uTint; uniform sampler2D uSampler; uniform bool isTexture; +uniform sampler2D uSpecularSampler; +uniform bool uHasSpecularTex; IN vec3 vNormal; IN vec2 vTexCoord; @@ -57,7 +59,9 @@ void main(void) { inputs.shininess = uShininess; inputs.metalness = uMetallic; inputs.ambientMaterial = uHasSetAmbient ? uAmbientMatColor.rgb : inputs.color.rgb; - inputs.specularMaterial = uSpecularMatColor.rgb; + inputs.specularMaterial = uHasSpecularTex + ? TEXTURE(uSpecularSampler, vTexCoord).rgb * uSpecularMatColor.rgb + : uSpecularMatColor.rgb; inputs.emissiveMaterial = uEmissiveMatColor.rgb; inputs = HOOK_getPixelInputs(inputs); diff --git a/test/unit/io/parseMtl.js b/test/unit/io/parseMtl.js index c01ef7947a..53a2b67638 100644 --- a/test/unit/io/parseMtl.js +++ b/test/unit/io/parseMtl.js @@ -69,4 +69,23 @@ suite('mtlToPartState', function () { expect(state.fill).toBeNull(); expect(state.texture).toBeNull(); }); + + test('a specular map lands on the part state with a white base', function () { + const img = { width: 1, height: 1 }; + const state = mtlToPartState({ specularTexture: img }); + expect(state.specularTexture).toBe(img); + // with no explicit Ks, the base specular colour defaults to white so the + // map has something to modulate + expect(state.specularColor).toEqual([1, 1, 1]); + }); + + test('a specular map keeps an explicit Ks colour', function () { + const img = { width: 1, height: 1 }; + const state = mtlToPartState({ + specularColor: [0.5, 0.5, 0.5], + specularTexture: img + }); + expect(state.specularTexture).toBe(img); + expect(state.specularColor).toEqual([0.5, 0.5, 0.5]); + }); }); diff --git a/test/unit/webgl/p5.GeometryPart.js b/test/unit/webgl/p5.GeometryPart.js index 00cde74b0b..ce276f6df3 100644 --- a/test/unit/webgl/p5.GeometryPart.js +++ b/test/unit/webgl/p5.GeometryPart.js @@ -40,7 +40,8 @@ suite('p5.GeometryPart', function () { ambientColor: null, specularColor: null, shininess: null, - texture: null + texture: null, + specularTexture: null }); }); From 26afa19518b28019f29d3ed9d01cea39d01b4034 Mon Sep 17 00:00:00 2001 From: nityam Date: Sun, 9 Aug 2026 23:55:00 +0530 Subject: [PATCH 2/8] render map_Ka ambient textures per part --- src/core/p5.Renderer3D.js | 9 +++++++++ src/webgl/loading.js | 8 +++++++- src/webgl/p5.GeometryPart.js | 3 ++- src/webgl/shaders/phong.frag | 6 +++++- test/unit/io/parseMtl.js | 7 +++++++ test/unit/webgl/p5.GeometryPart.js | 3 ++- 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 7804508eb1..7e1a9853a9 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -149,6 +149,7 @@ export class Renderer3D extends Renderer { this.states._tex = null; this.states._specularTex = null; + this.states._ambientTex = null; this.states.textureMode = constants.IMAGE; this.states.textureWrapX = constants.CLAMP; this.states.textureWrapY = constants.CLAMP; @@ -677,6 +678,11 @@ 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); @@ -1577,6 +1583,9 @@ export class Renderer3D extends Renderer { // 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); fillShader.setUniform( 'uTint', this.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255] diff --git a/src/webgl/loading.js b/src/webgl/loading.js index fc48ca9ac7..d13cea69f7 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -104,6 +104,11 @@ function mtlToPartState(material) { // 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]; + } return state; } @@ -111,7 +116,8 @@ function mtlToPartState(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) + ['specularTexturePath', 'specularTexture'], // map_Ks (specular) + ['ambientTexturePath', 'ambientTexture'] // map_Ka (ambient) ]; // load each material's texture maps and hang them on the material so they land diff --git a/src/webgl/p5.GeometryPart.js b/src/webgl/p5.GeometryPart.js index 09c685ac30..0d285a0e29 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -14,7 +14,8 @@ function createPartState() { specularColor: null, // Ks -> [r, g, b] | null, each 0..1 shininess: null, // Ns -> number | null texture: null, // map_Kd -> p5.Image | null - specularTexture: null // map_Ks -> p5.Image | null + specularTexture: null, // map_Ks -> p5.Image | null + ambientTexture: null // map_Ka -> p5.Image | null }; } diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index a02e40ca0d..3a1b288115 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -12,6 +12,8 @@ uniform sampler2D uSampler; uniform bool isTexture; uniform sampler2D uSpecularSampler; uniform bool uHasSpecularTex; +uniform sampler2D uAmbientSampler; +uniform bool uHasAmbientTex; IN vec3 vNormal; IN vec2 vTexCoord; @@ -58,7 +60,9 @@ void main(void) { } inputs.shininess = uShininess; inputs.metalness = uMetallic; - inputs.ambientMaterial = uHasSetAmbient ? uAmbientMatColor.rgb : inputs.color.rgb; + inputs.ambientMaterial = uHasAmbientTex + ? TEXTURE(uAmbientSampler, vTexCoord).rgb * uAmbientMatColor.rgb + : (uHasSetAmbient ? uAmbientMatColor.rgb : inputs.color.rgb); inputs.specularMaterial = uHasSpecularTex ? TEXTURE(uSpecularSampler, vTexCoord).rgb * uSpecularMatColor.rgb : uSpecularMatColor.rgb; diff --git a/test/unit/io/parseMtl.js b/test/unit/io/parseMtl.js index 53a2b67638..31dbec81ee 100644 --- a/test/unit/io/parseMtl.js +++ b/test/unit/io/parseMtl.js @@ -88,4 +88,11 @@ suite('mtlToPartState', function () { expect(state.specularTexture).toBe(img); expect(state.specularColor).toEqual([0.5, 0.5, 0.5]); }); + + test('an ambient map lands on the part state with a white base', function () { + const img = { width: 1, height: 1 }; + const state = mtlToPartState({ ambientTexture: img }); + expect(state.ambientTexture).toBe(img); + expect(state.ambientColor).toEqual([1, 1, 1]); + }); }); diff --git a/test/unit/webgl/p5.GeometryPart.js b/test/unit/webgl/p5.GeometryPart.js index ce276f6df3..975d71e901 100644 --- a/test/unit/webgl/p5.GeometryPart.js +++ b/test/unit/webgl/p5.GeometryPart.js @@ -41,7 +41,8 @@ suite('p5.GeometryPart', function () { specularColor: null, shininess: null, texture: null, - specularTexture: null + specularTexture: null, + ambientTexture: null }); }); From 8a868d33caf8402ac09751b4f9edced268b48a14 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 10 Aug 2026 00:00:18 +0530 Subject: [PATCH 3/8] parse and render map_Ns shininess textures per part --- src/core/p5.Renderer3D.js | 10 ++++++++++ src/webgl/loading.js | 11 ++++++++++- src/webgl/p5.GeometryPart.js | 3 ++- src/webgl/shaders/phong.frag | 6 +++++- test/unit/io/parseMtl.js | 10 ++++++++++ test/unit/webgl/p5.GeometryPart.js | 3 ++- 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 7e1a9853a9..af36ef733c 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -150,6 +150,7 @@ export class Renderer3D extends Renderer { this.states._tex = null; this.states._specularTex = null; this.states._ambientTex = null; + this.states._shininessTex = null; this.states.textureMode = constants.IMAGE; this.states.textureWrapX = constants.CLAMP; this.states.textureWrapY = constants.CLAMP; @@ -695,6 +696,9 @@ export class Renderer3D extends Renderer { if (partState.shininess != null) { this.states.setValue('_useShininess', partState.shininess); } + if (partState.shininessTexture) { + this.states.setValue('_shininessTex', partState.shininessTexture); + } } _drawStrokes(geometry, { count } = {}) { @@ -1586,6 +1590,12 @@ export class Renderer3D extends Renderer { // 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 + ); fillShader.setUniform( 'uTint', this.states.tint?._getRGBA([255, 255, 255, 255]) ?? [255, 255, 255, 255] diff --git a/src/webgl/loading.js b/src/webgl/loading.js index d13cea69f7..603a75afaa 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -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. @@ -109,6 +112,11 @@ function mtlToPartState(material) { // 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; + } return state; } @@ -117,7 +125,8 @@ function mtlToPartState(material) { const MATERIAL_TEXTURE_MAPS = [ ['texturePath', 'texture'], // map_Kd (diffuse) ['specularTexturePath', 'specularTexture'], // map_Ks (specular) - ['ambientTexturePath', 'ambientTexture'] // map_Ka (ambient) + ['ambientTexturePath', 'ambientTexture'], // map_Ka (ambient) + ['shininessTexturePath', 'shininessTexture'] // map_Ns (shininess) ]; // load each material's texture maps and hang them on the material so they land diff --git a/src/webgl/p5.GeometryPart.js b/src/webgl/p5.GeometryPart.js index 0d285a0e29..5873ac8959 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -15,7 +15,8 @@ function createPartState() { shininess: null, // Ns -> number | null texture: null, // map_Kd -> p5.Image | null specularTexture: null, // map_Ks -> p5.Image | null - ambientTexture: null // map_Ka -> p5.Image | null + ambientTexture: null, // map_Ka -> p5.Image | null + shininessTexture: null // map_Ns -> p5.Image | null }; } diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 3a1b288115..144eb33095 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -14,6 +14,8 @@ uniform sampler2D uSpecularSampler; uniform bool uHasSpecularTex; uniform sampler2D uAmbientSampler; uniform bool uHasAmbientTex; +uniform sampler2D uShininessSampler; +uniform bool uHasShininessTex; IN vec3 vNormal; IN vec2 vTexCoord; @@ -58,7 +60,9 @@ void main(void) { // so hooks users don't have to think about premultiplied alpha. inputs.color.rgb /= inputs.color.a; } - inputs.shininess = uShininess; + inputs.shininess = uHasShininessTex + ? uShininess * TEXTURE(uShininessSampler, vTexCoord).r + : uShininess; inputs.metalness = uMetallic; inputs.ambientMaterial = uHasAmbientTex ? TEXTURE(uAmbientSampler, vTexCoord).rgb * uAmbientMatColor.rgb diff --git a/test/unit/io/parseMtl.js b/test/unit/io/parseMtl.js index 31dbec81ee..eef4fd9269 100644 --- a/test/unit/io/parseMtl.js +++ b/test/unit/io/parseMtl.js @@ -13,6 +13,7 @@ suite('parseMtlData', function () { 'map_Kd diffuse.png', 'map_Ka ambient.png', 'map_Ks specular.png', + 'map_Ns shininess.png', 'map_Bump -bm 0.5 bump.png' ].join('\n'); @@ -28,6 +29,7 @@ suite('parseMtlData', function () { expect(m.texturePath).toEqual('diffuse.png'); expect(m.ambientTexturePath).toEqual('ambient.png'); expect(m.specularTexturePath).toEqual('specular.png'); + expect(m.shininessTexturePath).toEqual('shininess.png'); // bump options like -bm precede the path, so the path is the last token. expect(m.bumpTexturePath).toEqual('bump.png'); }); @@ -95,4 +97,12 @@ suite('mtlToPartState', function () { expect(state.ambientTexture).toBe(img); expect(state.ambientColor).toEqual([1, 1, 1]); }); + + test('a shininess map lands on the part state', function () { + const img = { width: 1, height: 1 }; + const state = mtlToPartState({ shininessTexture: img }); + expect(state.shininessTexture).toBe(img); + // the map scales a base shininess, which defaults to 1 + expect(state.shininess).toEqual(1); + }); }); diff --git a/test/unit/webgl/p5.GeometryPart.js b/test/unit/webgl/p5.GeometryPart.js index 975d71e901..9bb22354f7 100644 --- a/test/unit/webgl/p5.GeometryPart.js +++ b/test/unit/webgl/p5.GeometryPart.js @@ -42,7 +42,8 @@ suite('p5.GeometryPart', function () { shininess: null, texture: null, specularTexture: null, - ambientTexture: null + ambientTexture: null, + shininessTexture: null }); }); From ba4f2240750d7d6edd83aed11273ce19e92d45e9 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 10 Aug 2026 00:35:32 +0530 Subject: [PATCH 4/8] include texture maps in the per-part material check --- src/core/p5.Renderer3D.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index af36ef733c..5a0d22a0d0 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -614,7 +614,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); From bda4c4ac4e079bf428d99a5818c06e5faefa5e8c Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 10 Aug 2026 00:45:35 +0530 Subject: [PATCH 5/8] carry map_Bump normal texture on part state (data layer) --- src/webgl/loading.js | 4 +++- src/webgl/p5.GeometryPart.js | 3 ++- test/unit/webgl/p5.GeometryPart.js | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/webgl/loading.js b/src/webgl/loading.js index 603a75afaa..be0f4bee6f 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -117,6 +117,7 @@ function mtlToPartState(material) { // 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; } @@ -126,7 +127,8 @@ const MATERIAL_TEXTURE_MAPS = [ ['texturePath', 'texture'], // map_Kd (diffuse) ['specularTexturePath', 'specularTexture'], // map_Ks (specular) ['ambientTexturePath', 'ambientTexture'], // map_Ka (ambient) - ['shininessTexturePath', 'shininessTexture'] // map_Ns (shininess) + ['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 diff --git a/src/webgl/p5.GeometryPart.js b/src/webgl/p5.GeometryPart.js index 5873ac8959..3c3099a927 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -16,7 +16,8 @@ function createPartState() { 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 + shininessTexture: null, // map_Ns -> p5.Image | null + normalTexture: null // map_Bump -> p5.Image | null }; } diff --git a/test/unit/webgl/p5.GeometryPart.js b/test/unit/webgl/p5.GeometryPart.js index 9bb22354f7..e13e0a1e8e 100644 --- a/test/unit/webgl/p5.GeometryPart.js +++ b/test/unit/webgl/p5.GeometryPart.js @@ -43,7 +43,8 @@ suite('p5.GeometryPart', function () { texture: null, specularTexture: null, ambientTexture: null, - shininessTexture: null + shininessTexture: null, + normalTexture: null }); }); From 7ea0641fafe4fa0dd1bbb6c675faf69e630f5ad7 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 10 Aug 2026 01:56:55 +0530 Subject: [PATCH 6/8] add computeTangents() for normal mapping --- src/webgl/p5.Geometry.js | 79 ++++++++++++++++++++++++++++++++++ test/unit/webgl/p5.Geometry.js | 50 +++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/src/webgl/p5.Geometry.js b/src/webgl/p5.Geometry.js index cd842eaa67..783381b682 100644 --- a/src/webgl/p5.Geometry.js +++ b/src/webgl/p5.Geometry.js @@ -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 = []; @@ -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) { @@ -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 diff --git a/test/unit/webgl/p5.Geometry.js b/test/unit/webgl/p5.Geometry.js index 44b492b859..b26b1a748b 100644 --- a/test/unit/webgl/p5.Geometry.js +++ b/test/unit/webgl/p5.Geometry.js @@ -15,6 +15,56 @@ suite('p5.Geometry', function () { myp5.remove(); }); + suite('computeTangents', function () { + test('a uv-mapped triangle gets a +u tangent with correct handedness', + function () { + const geom = new p5.Geometry(); + // triangle in the xy plane, facing +z, uvs aligned to x (u) and y (v) + geom.vertices.push( + myp5.createVector(0, 0, 0), + myp5.createVector(1, 0, 0), + myp5.createVector(0, 1, 0) + ); + geom.uvs.push(0, 0, 1, 0, 0, 1); + geom.vertexNormals.push( + myp5.createVector(0, 0, 1), + myp5.createVector(0, 0, 1), + myp5.createVector(0, 0, 1) + ); + geom.faces.push([0, 1, 2]); + + geom.computeTangents(); + + // 4 components per vertex (x, y, z, handedness) + expect(geom.vertexTangents.length).toEqual(12); + // tangent points along +u (the +x direction here), handedness +1 + for (let i = 0; i < 3; i++) { + expect(geom.vertexTangents[i * 4]).toBeCloseTo(1, 5); + expect(geom.vertexTangents[i * 4 + 1]).toBeCloseTo(0, 5); + expect(geom.vertexTangents[i * 4 + 2]).toBeCloseTo(0, 5); + expect(geom.vertexTangents[i * 4 + 3]).toEqual(1); + } + } + ); + + test('no uvs means no tangents', function () { + const geom = new p5.Geometry(); + geom.vertices.push( + myp5.createVector(0, 0, 0), + myp5.createVector(1, 0, 0), + myp5.createVector(0, 1, 0) + ); + geom.vertexNormals.push( + myp5.createVector(0, 0, 1), + myp5.createVector(0, 0, 1), + myp5.createVector(0, 0, 1) + ); + geom.faces.push([0, 1, 2]); + geom.computeTangents(); + expect(geom.vertexTangents.length).toEqual(0); + }); + }); + suite('generating edge geometry', function () { let geom; From 1691f9698c74d9d5700b975b65aa6b52f3f14b46 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 10 Aug 2026 02:05:09 +0530 Subject: [PATCH 7/8] render map_Bump normal maps with tangent-space perturbation --- src/core/p5.Renderer3D.js | 19 ++++++++++++++++++- src/webgl/loading.js | 18 ++++++++++++++++++ src/webgl/p5.GeometryPart.js | 2 ++ src/webgl/shaders/phong.frag | 14 +++++++++++++- src/webgl/shaders/phong.vert | 9 +++++++++ 5 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 5a0d22a0d0..c9ed3d3866 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -151,6 +151,7 @@ export class Renderer3D extends Renderer { 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; @@ -295,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( @@ -702,6 +713,9 @@ export class Renderer3D extends Renderer { if (partState.shininessTexture) { this.states.setValue('_shininessTex', partState.shininessTexture); } + if (partState.normalTexture) { + this.states.setValue('_normalTex', partState.normalTexture); + } } _drawStrokes(geometry, { count } = {}) { @@ -1599,6 +1613,9 @@ export class Renderer3D extends Renderer { '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] diff --git a/src/webgl/loading.js b/src/webgl/loading.js index be0f4bee6f..b22412daa9 100755 --- a/src/webgl/loading.js +++ b/src/webgl/loading.js @@ -175,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) { @@ -192,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); }); @@ -815,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); diff --git a/src/webgl/p5.GeometryPart.js b/src/webgl/p5.GeometryPart.js index 3c3099a927..b10209cbdf 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -31,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 = []; diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 144eb33095..d8b9b33648 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -16,11 +16,14 @@ uniform sampler2D uAmbientSampler; uniform bool uHasAmbientTex; uniform sampler2D uShininessSampler; uniform bool uHasShininessTex; +uniform sampler2D uNormalSampler; +uniform bool uHasNormalMap; IN vec3 vNormal; IN vec2 vTexCoord; IN vec3 vViewPosition; IN vec4 vColor; +IN vec4 vTangent; struct ColorComponents { vec3 baseColor; @@ -49,7 +52,16 @@ void main(void) { HOOK_beforeFragment(); Inputs inputs; - inputs.normal = normalize(vNormal); + vec3 N = normalize(vNormal); + if (uHasNormalMap) { + // rebuild the tangent basis (TBN) and perturb the normal by the map. + vec3 T = normalize(vTangent.xyz); + T = normalize(T - N * dot(N, T)); + vec3 B = cross(N, T) * vTangent.w; + vec3 mapN = TEXTURE(uNormalSampler, vTexCoord).rgb * 2.0 - 1.0; + N = normalize(mat3(T, B, N) * mapN); + } + inputs.normal = N; inputs.texCoord = vTexCoord; inputs.ambientLight = uAmbientColor; inputs.color = isTexture diff --git a/src/webgl/shaders/phong.vert b/src/webgl/shaders/phong.vert index 49a10933fc..8d7bb8bdb4 100644 --- a/src/webgl/shaders/phong.vert +++ b/src/webgl/shaders/phong.vert @@ -6,6 +6,7 @@ IN vec3 aPosition; IN vec3 aNormal; IN vec2 aTexCoord; IN vec4 aVertexColor; +IN vec4 aTangent; #ifdef AUGMENTED_HOOK_getWorldInputs uniform mat4 uModelMatrix; @@ -26,6 +27,7 @@ OUT vec2 vTexCoord; OUT vec3 vViewPosition; OUT vec3 vAmbientColor; OUT vec4 vColor; +OUT vec4 vTangent; struct Vertex { vec3 position; @@ -42,6 +44,9 @@ void main(void) { inputs.normal = aNormal; inputs.texCoord = aTexCoord; inputs.color = (uUseVertexColor && aVertexColor.x >= 0.0) ? aVertexColor : uMaterialColor; + // transform the surface tangent alongside the normal so it ends up in the + // same space; handedness (w) is passed through for rebuilding the bitangent. + vec3 tangent = aTangent.xyz; #ifdef AUGMENTED_HOOK_getObjectInputs inputs = HOOK_getObjectInputs(inputs); #endif @@ -49,6 +54,7 @@ void main(void) { #ifdef AUGMENTED_HOOK_getWorldInputs inputs.position = (uModelMatrix * vec4(inputs.position, 1.)).xyz; inputs.normal = uModelNormalMatrix * inputs.normal; + tangent = uModelNormalMatrix * tangent; inputs = HOOK_getWorldInputs(inputs); #endif @@ -56,10 +62,12 @@ void main(void) { // Already multiplied by the model matrix, just apply view inputs.position = (uViewMatrix * vec4(inputs.position, 1.)).xyz; inputs.normal = uCameraNormalMatrix * inputs.normal; + tangent = uCameraNormalMatrix * tangent; #else // Apply both at once inputs.position = (uModelViewMatrix * vec4(inputs.position, 1.)).xyz; inputs.normal = uNormalMatrix * inputs.normal; + tangent = uNormalMatrix * tangent; #endif #ifdef AUGMENTED_HOOK_getCameraInputs inputs = HOOK_getCameraInputs(inputs); @@ -70,6 +78,7 @@ void main(void) { vTexCoord = inputs.texCoord; vNormal = inputs.normal; vColor = inputs.color; + vTangent = vec4(tangent, aTangent.w); gl_Position = uProjectionMatrix * vec4(inputs.position, 1.); HOOK_afterVertex(); From 61f213a44cf03446b297e3619e63bb1f71d81a55 Mon Sep 17 00:00:00 2001 From: nityam Date: Mon, 10 Aug 2026 02:09:37 +0530 Subject: [PATCH 8/8] test normal mapping: tangent data flow and render --- test/unit/assets/normal_mapped.mtl | 6 +++++ test/unit/assets/normal_mapped.obj | 17 +++++++++++++ test/unit/io/loadModel.js | 24 ++++++++++++++++++ test/unit/io/parseMtl.js | 6 +++++ test/unit/visual/cases/webgl.js | 16 ++++++++++++ .../000.png | Bin 0 -> 792 bytes .../metadata.json | 3 +++ 7 files changed, 72 insertions(+) create mode 100644 test/unit/assets/normal_mapped.mtl create mode 100644 test/unit/assets/normal_mapped.obj create mode 100644 test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/000.png create mode 100644 test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/metadata.json diff --git a/test/unit/assets/normal_mapped.mtl b/test/unit/assets/normal_mapped.mtl new file mode 100644 index 0000000000..75391a4a70 --- /dev/null +++ b/test/unit/assets/normal_mapped.mtl @@ -0,0 +1,6 @@ +newmtl m0 +Kd 0.8 0.8 0.8 +map_Bump spheremap.jpg + +newmtl m1 +Kd 0.5 0.5 0.5 diff --git a/test/unit/assets/normal_mapped.obj b/test/unit/assets/normal_mapped.obj new file mode 100644 index 0000000000..971557ca7f --- /dev/null +++ b/test/unit/assets/normal_mapped.obj @@ -0,0 +1,17 @@ +mtllib normal_mapped.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 +vn 0 0 1 +vn 0 0 1 +vn 0 0 1 +vn 0 0 1 +usemtl m0 +f 1/1/1 2/2/2 3/3/3 +usemtl m1 +f 2/2/2 4/4/4 3/3/3 diff --git a/test/unit/io/loadModel.js b/test/unit/io/loadModel.js index 0c2efa9cf7..d65bfa4ae2 100644 --- a/test/unit/io/loadModel.js +++ b/test/unit/io/loadModel.js @@ -116,6 +116,30 @@ suite('loadModel', function () { } }); + test('a normal-mapped OBJ computes tangents and carries the map', async function () { + const fakeImage = { width: 1, height: 1 }; + mockP5Prototype.loadImage = async () => fakeImage; + try { + const model = await mockP5Prototype.loadModel( + '/test/unit/assets/normal_mapped.obj' + ); + // two materials, so two parts + assert.equal(model.parts.length, 2); + // tangents were computed on the aggregate for the normal map + assert.isAbove(model.vertexTangents.length, 0); + // the part with the normal map carries it and got its own tangents + const normalMapped = model.parts.find(p => p.partState.normalTexture); + assert.ok(normalMapped, 'a part has the normal map'); + assert.equal(normalMapped.partState.normalTexture, fakeImage); + assert.equal( + normalMapped.vertexTangents.length, + normalMapped.vertices.length * 4 + ); + } finally { + delete mockP5Prototype.loadImage; + } + }); + test('a texture that fails to load is skipped without failing the model', async function () { mockP5Prototype.loadImage = async () => { throw new Error('Not Found'); diff --git a/test/unit/io/parseMtl.js b/test/unit/io/parseMtl.js index eef4fd9269..1a7dc30c17 100644 --- a/test/unit/io/parseMtl.js +++ b/test/unit/io/parseMtl.js @@ -105,4 +105,10 @@ suite('mtlToPartState', function () { // the map scales a base shininess, which defaults to 1 expect(state.shininess).toEqual(1); }); + + test('a normal map lands on the part state', function () { + const img = { width: 1, height: 1 }; + const state = mtlToPartState({ normalTexture: img }); + expect(state.normalTexture).toBe(img); + }); }); diff --git a/test/unit/visual/cases/webgl.js b/test/unit/visual/cases/webgl.js index ed2af5fdc6..49b5db331f 100644 --- a/test/unit/visual/cases/webgl.js +++ b/test/unit/visual/cases/webgl.js @@ -411,6 +411,22 @@ visualSuite('WebGL', function () { screenshot(); } ); + visualTest( + 'a normal-mapped OBJ renders under light', + async function (p5, screenshot) { + p5.createCanvas(50, 50, p5.WEBGL); + // normal_mapped.obj carries a map_Bump on one of its materials + const model = await new Promise(resolve => + p5.loadModel('test/unit/assets/normal_mapped.obj', resolve) + ); + model.normalize(); + p5.background(255); + p5.pointLight(255, 255, 255, 0, 0, 200); + p5.noStroke(); + p5.model(model); + screenshot(); + } + ); }); visualSuite('vertexProperty', function () { diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/000.png b/test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/000.png new file mode 100644 index 0000000000000000000000000000000000000000..50c4bca103041315a25030dcac67949d6379d476 GIT binary patch literal 792 zcmV+z1LypSP)=4(J^k4kx9(U(^hmoJjm85YHb;72HUvSb1_1zsZAdK$03bj>sRRK41OTPf zfdBwf0LrEc1OSjK-fdF@0szQeyxUI&2ml~=;a)%SAkAh|o)g}}z5PUkCQ#SjGs*uhzm!yJ5FH4=-N1MvK!QRrKktfhkf0E_ z`>$jaBrFJk83Xwig8BFH3KIl3MoZs9yjmo7kSHJk7Ar_p5V3+p2@xAe)DW?N!~qdG zNL&z+g2V|C8A#j^p@Z~#J%Zl-qW}wstx%sM#bS|4rBc{6YUO(Zsnu!{I2;be>NYq| zmi3fQH$3pX#{=Jn{11CtdAHli)x}~VR;K}kiR0w1%^C~_G#ZVZv3|c#s?0G@H$&|E@6w;XPKU(<&4Su%g@TMwv`T{CABh2*0|lY&Pp0 ztz0gr1!5B7)ouXJI-k!}E|=vvolYm3OePrjl}QNSqU;OyZ%m7>*DGBv7kLJByIp(u z^~*Gb-)^8<0l|d({Vp)fzyB7-Bq+%cs82xbh9G99^?FU6P6yW<8GvxshJzao`w(Q9SD>qAeancS1_mq2nc;#hmBJ~))WwCmio5a ztynYwfZ!_yNvvW%wL#ai5P+RvW?C+nvMvB1>3AB)dI z@9X>^+{qCGxyQcokqi1Xha4>x+-|o8MD=wn7FW`l@1CRLVBO=N_yU5@SF6>E4u``h zIh+_26huy72#$apkH^3}5yIM`rm(OJ`~5!Dq$ZO60{{U3|EEg_%>V!Z21!IgR09D1 W!6j_$K?@lG0000