Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions src/core/p5.Renderer3D.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export class Renderer3D extends Renderer {
this.states._specularTex = null;
this.states._ambientTex = null;
this.states._shininessTex = null;
this.states._normalTex = null;
this.states._normalScale = 1;
this.states.textureMode = constants.IMAGE;
this.states.textureWrapX = constants.CLAMP;
this.states.textureWrapY = constants.CLAMP;
Expand Down Expand Up @@ -295,7 +297,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.
// only computed for models with a normal map, and only read by the map
// shader variant; defaults to a dummy so the attribute stays valid.
new RenderBuffer(
4,
'vertexTangents',
'tangentBuffer',
'aTangent',
this
).default(geometry => geometry.vertices.flatMap(() => [0, 0, 0, 1]))
],
stroke: [
new RenderBuffer(
Expand Down Expand Up @@ -640,6 +652,19 @@ export class Renderer3D extends Renderer {
this._useVertexColor =
geometry.vertexColors.length > 0 && !geometry.vertexColors.isDefault;

// a normal map needs per-vertex tangents. loaded models compute them at load,
// but geometry drawn with normalTexture() (built shapes, immediate mode) won't
// have them, so build them on demand once (cached on the geometry).
if (
this.states._normalTex &&
geometry.computeTangents &&
(!geometry.vertexTangents || geometry.vertexTangents.length === 0) &&
geometry.uvs.length > 0 &&
geometry.vertexNormals.length > 0
) {
geometry.computeTangents();
}

const shader =
!this._drawingFilter && this.states.userFillShader
? this.states.userFillShader
Expand Down Expand Up @@ -702,6 +727,12 @@ export class Renderer3D extends Renderer {
if (partState.shininessTexture) {
this.states.setValue('_shininessTex', partState.shininessTexture);
}
if (partState.normalTexture) {
this.states.setValue('_normalTex', partState.normalTexture);
if (partState.normalScale != null) {
this.states.setValue('_normalScale', partState.normalScale);
}
}
}

_drawStrokes(geometry, { count } = {}) {
Expand Down Expand Up @@ -1483,13 +1514,14 @@ export class Renderer3D extends Renderer {
}

// true when the current material has any mtl texture map bound (specular,
// ambient or shininess). drives both shader-variant selection and whether
// the map uniforms are worth binding at all.
// ambient, shininess or normal). drives both shader-variant selection and
// whether the map uniforms are worth binding at all.
_hasActiveTextureMap() {
return !!(
this.states._specularTex ||
this.states._ambientTex ||
this.states._shininessTex
this.states._shininessTex ||
this.states._normalTex
);
}

Expand Down Expand Up @@ -1601,7 +1633,8 @@ export class Renderer3D extends Renderer {
// mtl texture maps only exist in the USE_TEXTURE_MAPS shader variant, which
// is only selected when a part actually binds one. so for the common case
// (lit scene, no maps) we skip all of these setUniform calls entirely and
// the plain phong shader carries none of the map uniforms.
// the plain phong shader carries none of the map uniforms. bind all four
// pairs together since the variant declares all of them.
if (this._hasActiveTextureMap()) {
// specular map (map_Ks)
fillShader.setUniform('uHasSpecularTex', !!this.states._specularTex);
Expand All @@ -1615,6 +1648,10 @@ 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('uNormalScale', this.states._normalScale);
}
fillShader.setUniform(
'uTint',
Expand Down
38 changes: 35 additions & 3 deletions src/webgl/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,15 @@ function parseMtlData(data) {
//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.
//bump map. the path is the last token; a `-bm <value>` option can precede
//it to scale the bump strength (maps often use the full range for precision
//and get scaled down here).
materials[currentMaterial].bumpTexturePath = tokens[tokens.length - 1];
const bmIndex = tokens.indexOf('-bm');
if (bmIndex !== -1 && tokens[bmIndex + 1] !== undefined) {
const bm = parseFloat(tokens[bmIndex + 1]);
if (!isNaN(bm)) materials[currentMaterial].bumpScale = bm;
}
}
}

Expand Down Expand Up @@ -117,6 +123,11 @@ 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;
// a -bm multiplier scales the bump strength; defaults to 1 when omitted
if (material.bumpScale != null) state.normalScale = material.bumpScale;
}
return state;
}

Expand All @@ -126,7 +137,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
Expand Down Expand Up @@ -173,6 +185,7 @@ function buildMaterialParts(model, faceMaterials, materials) {

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

for (const name of names) {
Expand All @@ -190,6 +203,14 @@ function buildMaterialParts(model, faceMaterials, materials) {
part.vertices.push(model.vertices[vi]);
if (hasUvs) part.uvs.push(model.uvs[vi]);
if (hasNormals) part.vertexNormals.push(model.vertexNormals[vi]);
if (hasTangents) {
part.vertexTangents.push(
model.vertexTangents[vi * 4],
model.vertexTangents[vi * 4 + 1],
model.vertexTangents[vi * 4 + 2],
model.vertexTangents[vi * 4 + 3]
);
}
}
return localIndex.get(vi);
});
Expand Down Expand Up @@ -813,6 +834,17 @@ 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.
// only done when a material actually uses a normal map, so plain models pay
// nothing extra.
const needsTangents = Object.values(materials).some(
m => m && m.normalTexture
);
if (needsTangents) {
model.computeTangents();
}

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

Expand Down
141 changes: 141 additions & 0 deletions src/webgl/material.js
Original file line number Diff line number Diff line change
Expand Up @@ -2566,6 +2566,124 @@ function material(p5, fn) {
return this;
};

/**
* Sets a normal map to add surface detail to shapes under lighting.
*
* `normalTexture()` works like <a href="#/p5/texture">texture()</a>, but for a
* tangent-space normal map: an image whose red, green, and blue channels
* encode the direction of the surface normal (not brightness). Call it before
* drawing a shape and its surface normals get perturbed by the map, so lights
* react to detail that isn't actually in the geometry. This is the same kind
* of map glTF models use. Pass an optional `scale` to tune the strength.
*
* Call `normalTexture(null)` to turn it off, or scope it between
* <a href="#/p5/push">push()</a> and <a href="#/p5/pop">pop()</a>.
*
* @method normalTexture
* @param {p5.Image|p5.MediaElement|p5.Graphics|p5.Texture|p5.Framebuffer|p5.FramebufferTexture} tex normal map, or `null` to clear it.
* @param {Number} [scale=1] strength multiplier for the surface detail.
* @chainable
*
* @example
* <div>
* <code>
* let normalMap;
*
* function setup() {
* createCanvas(100, 100, WEBGL);
*
* // build a small tangent-space normal map with diagonal ridges
* normalMap = createImage(32, 32);
* normalMap.loadPixels();
* for (let y = 0; y < normalMap.height; y += 1) {
* for (let x = 0; x < normalMap.width; x += 1) {
* let s = sin(((x + y) / normalMap.width) * TWO_PI * 3) * 0.8;
* let inv = 1 / sqrt(s * s + s * s + 1);
* let i = (x + y * normalMap.width) * 4;
* normalMap.pixels[i] = (s * inv * 0.5 + 0.5) * 255;
* normalMap.pixels[i + 1] = (s * inv * 0.5 + 0.5) * 255;
* normalMap.pixels[i + 2] = (inv * 0.5 + 0.5) * 255;
* normalMap.pixels[i + 3] = 255;
* }
* }
* normalMap.updatePixels();
*
* describe('A sphere lit from the left, its surface covered in ridges from a normal map.');
* }
*
* function draw() {
* background(0);
* pointLight(255, 255, 255, -50, -50, 100);
* noStroke();
* fill(200);
* normalTexture(normalMap);
* sphere(40);
* }
* </code>
* </div>
*/
fn.normalTexture = function (tex, scale) {
this._assert3d('normalTexture');
this._renderer.normalTexture(tex || null, scale);

return this;
};

/**
* Sets a specular map to vary the specular highlight across a shape's surface.
*
* Works like <a href="#/p5/texture">texture()</a> but for the specular colour,
* modulating <a href="#/p5/specularMaterial">specularMaterial()</a> per pixel.
* Call `specularTexture(null)` to clear it, or scope it with push()/pop().
*
* @method specularTexture
* @param {p5.Image|p5.MediaElement|p5.Graphics|p5.Texture|p5.Framebuffer|p5.FramebufferTexture} tex specular map, or `null` to clear it.
* @chainable
*/
fn.specularTexture = function (tex) {
this._assert3d('specularTexture');
this._renderer.specularTexture(tex || null);

return this;
};

/**
* Sets an ambient map to vary the ambient colour across a shape's surface.
*
* Works like <a href="#/p5/texture">texture()</a> but for the ambient colour,
* modulating <a href="#/p5/ambientMaterial">ambientMaterial()</a> per pixel.
* Call `ambientTexture(null)` to clear it, or scope it with push()/pop().
*
* @method ambientTexture
* @param {p5.Image|p5.MediaElement|p5.Graphics|p5.Texture|p5.Framebuffer|p5.FramebufferTexture} tex ambient map, or `null` to clear it.
* @chainable
*/
fn.ambientTexture = function (tex) {
this._assert3d('ambientTexture');
this._renderer.ambientTexture(tex || null);

return this;
};

/**
* Sets a shininess map to vary the shininess across a shape's surface.
*
* Works like <a href="#/p5/texture">texture()</a> but for shininess: the map's
* red channel scales the base <a href="#/p5/shininess">shininess()</a> value
* per pixel. Call `shininessTexture(null)` to clear it, or scope it with
* push()/pop().
*
* @method shininessTexture
* @param {p5.Image|p5.MediaElement|p5.Graphics|p5.Texture|p5.Framebuffer|p5.FramebufferTexture} tex shininess map, or `null` to clear it.
* @chainable
*/
fn.shininessTexture = function (tex) {
this._assert3d('shininessTexture');
this._renderer.shininessTexture(tex || null);

return this;
};

/**
* Changes the coordinate system used for textures when they’re applied to
* custom shapes.
Expand Down Expand Up @@ -3819,6 +3937,29 @@ function material(p5, fn) {
this.states.setValue('fillColor', new Color([1, 1, 1]));
};

Renderer3D.prototype.normalTexture = function (tex, scale = 1) {
// null clears the map (back to the plain shader variant); a value sets the
// normal map + its strength. push()/pop() scopes it like any other state.
this.states.setValue('_normalTex', tex || null);
this.states.setValue('_normalScale', tex ? scale : 1);
};

// the remaining map setters mirror _applyPartState: setting a map also turns
// on the material term it modulates, so the map has something to affect.
Renderer3D.prototype.specularTexture = function (tex) {
this.states.setValue('_specularTex', tex || null);
if (tex) this.states.setValue('_useSpecularMaterial', true);
};

Renderer3D.prototype.ambientTexture = function (tex) {
this.states.setValue('_ambientTex', tex || null);
if (tex) this.states.setValue('_hasSetAmbient', true);
};

Renderer3D.prototype.shininessTexture = function (tex) {
this.states.setValue('_shininessTex', tex || null);
};

Renderer3D.prototype.normalMaterial = function (...args) {
this.states.setValue('drawMode', constants.FILL);
this.states.setValue('_useSpecularMaterial', false);
Expand Down
Loading
Loading