From bc374f1d31e463eb327355a573a9eeec97e37e3c Mon Sep 17 00:00:00 2001 From: nityam Date: Sun, 9 Aug 2026 23:50:11 +0530 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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);