diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 455ec6d59c..5a0d22a0d0 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -148,6 +148,9 @@ 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.textureMode = constants.IMAGE; this.states.textureWrapX = constants.CLAMP; this.states.textureWrapY = constants.CLAMP; @@ -611,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); @@ -676,13 +682,26 @@ 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); + } } _drawStrokes(geometry, { count } = {}) { @@ -1567,6 +1586,19 @@ 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 + ); 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..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. @@ -98,13 +101,37 @@ 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; + } 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) +]; + +// 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 +142,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..5873ac8959 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -13,7 +13,10 @@ 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 }; } diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 47ec519d47..144eb33095 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -10,6 +10,12 @@ uniform vec4 uEmissiveMatColor; uniform vec4 uTint; uniform sampler2D uSampler; uniform bool isTexture; +uniform sampler2D uSpecularSampler; +uniform bool uHasSpecularTex; +uniform sampler2D uAmbientSampler; +uniform bool uHasAmbientTex; +uniform sampler2D uShininessSampler; +uniform bool uHasShininessTex; IN vec3 vNormal; IN vec2 vTexCoord; @@ -54,10 +60,16 @@ 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 = uHasSetAmbient ? uAmbientMatColor.rgb : inputs.color.rgb; - inputs.specularMaterial = uSpecularMatColor.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; inputs.emissiveMaterial = uEmissiveMatColor.rgb; inputs = HOOK_getPixelInputs(inputs); diff --git a/test/unit/io/parseMtl.js b/test/unit/io/parseMtl.js index c01ef7947a..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'); }); @@ -69,4 +71,38 @@ 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]); + }); + + 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]); + }); + + 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 00cde74b0b..9bb22354f7 100644 --- a/test/unit/webgl/p5.GeometryPart.js +++ b/test/unit/webgl/p5.GeometryPart.js @@ -40,7 +40,10 @@ suite('p5.GeometryPart', function () { ambientColor: null, specularColor: null, shininess: null, - texture: null + texture: null, + specularTexture: null, + ambientTexture: null, + shininessTexture: null }); });