diff --git a/src/core/p5.Renderer3D.js b/src/core/p5.Renderer3D.js index 455ec6d59c..c9ed3d3866 100644 --- a/src/core/p5.Renderer3D.js +++ b/src/core/p5.Renderer3D.js @@ -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; @@ -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( @@ -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); @@ -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 } = {}) { @@ -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] diff --git a/src/webgl/loading.js b/src/webgl/loading.js index f9a546f03b..b22412daa9 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,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; @@ -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); @@ -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) { @@ -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); }); @@ -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); 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/src/webgl/p5.GeometryPart.js b/src/webgl/p5.GeometryPart.js index 7afa6e152a..b10209cbdf 100644 --- a/src/webgl/p5.GeometryPart.js +++ b/src/webgl/p5.GeometryPart.js @@ -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 }; } @@ -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 = []; diff --git a/src/webgl/shaders/phong.frag b/src/webgl/shaders/phong.frag index 47ec519d47..d8b9b33648 100644 --- a/src/webgl/shaders/phong.frag +++ b/src/webgl/shaders/phong.frag @@ -10,11 +10,20 @@ 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; +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; @@ -43,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 @@ -54,10 +72,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/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(); 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 c01ef7947a..1a7dc30c17 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,44 @@ 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); + }); + + 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 0000000000..50c4bca103 Binary files /dev/null and b/test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/000.png differ diff --git a/test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/metadata.json b/test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/metadata.json new file mode 100644 index 0000000000..2d4bfe30da --- /dev/null +++ b/test/unit/visual/screenshots/WebGL/3DModel/a normal-mapped OBJ renders under light/metadata.json @@ -0,0 +1,3 @@ +{ + "numScreenshots": 1 +} \ No newline at end of file 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; diff --git a/test/unit/webgl/p5.GeometryPart.js b/test/unit/webgl/p5.GeometryPart.js index 00cde74b0b..e13e0a1e8e 100644 --- a/test/unit/webgl/p5.GeometryPart.js +++ b/test/unit/webgl/p5.GeometryPart.js @@ -40,7 +40,11 @@ suite('p5.GeometryPart', function () { ambientColor: null, specularColor: null, shininess: null, - texture: null + texture: null, + specularTexture: null, + ambientTexture: null, + shininessTexture: null, + normalTexture: null }); });