Skip to content
Merged
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
13 changes: 13 additions & 0 deletions src/core/p5.Renderer3D.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ export class Renderer3D extends Renderer {
this.states._shininessTex = null;
this.states._normalTex = null;
this.states._normalScale = 1;
// how to read _normalTex: 0 = tangent-space normal map (rgb is the normal),
// 1 = bump map (brightness is height, the normal comes from its slope)
this.states._normalMapMode = 0;
this.states.textureMode = constants.IMAGE;
this.states.textureWrapX = constants.CLAMP;
this.states.textureWrapY = constants.CLAMP;
Expand Down Expand Up @@ -732,6 +735,7 @@ export class Renderer3D extends Renderer {
if (partState.normalScale != null) {
this.states.setValue('_normalScale', partState.normalScale);
}
this.states.setValue('_normalMapMode', partState.normalMapMode ?? 0);
}
}

Expand Down Expand Up @@ -1652,6 +1656,15 @@ export class Renderer3D extends Renderer {
fillShader.setUniform('uHasNormalMap', !!this.states._normalTex);
fillShader.setUniform('uNormalSampler', this.states._normalTex || empty);
fillShader.setUniform('uNormalScale', this.states._normalScale);
fillShader.setUniform('uNormalMapMode', this.states._normalMapMode);
// a bump map reads its neighbours to find the slope, so it needs to know
// how far apart texels are. falls back to a sane size for sources that
// don't report their dimensions.
const normalTex = this.states._normalTex;
fillShader.setUniform('uNormalTexelSize', [
1 / (normalTex && normalTex.width ? normalTex.width : 256),
1 / (normalTex && normalTex.height ? normalTex.height : 256)
]);
}
fillShader.setUniform(
'uTint',
Expand Down
36 changes: 22 additions & 14 deletions src/webgl/loading.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,21 @@ function parseMtlData(data) {
//shininess texture
materials[currentMaterial].shininessTexturePath = tokens[1];
} else if (tokens[0] === 'map_Bump' || tokens[0] === 'bump') {
//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).
//bump map, brightness is height. `-bm <value>` can precede the path
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;
}
} else if (tokens[0] === 'norm') {
//normal map. not in the original spec, but what most exporters use
materials[currentMaterial].normalTexturePath = 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 @@ -123,9 +129,10 @@ 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
// one slot, mode says how to read it. norm wins if both are set
if (material.bumpTexture || material.normalTexture) {
state.normalTexture = material.normalTexture || material.bumpTexture;
state.normalMapMode = material.normalTexture ? 0 : 1;
if (material.bumpScale != null) state.normalScale = material.bumpScale;
}
return state;
Expand All @@ -138,7 +145,8 @@ const MATERIAL_TEXTURE_MAPS = [
['specularTexturePath', 'specularTexture'], // map_Ks (specular)
['ambientTexturePath', 'ambientTexture'], // map_Ka (ambient)
['shininessTexturePath', 'shininessTexture'], // map_Ns (shininess)
['bumpTexturePath', 'normalTexture'] // map_Bump (normal)
['bumpTexturePath', 'bumpTexture'], // map_Bump (height)
['normalTexturePath', 'normalTexture'] // norm (tangent-space normal)
];

// load each material's texture maps and hang them on the material so they land
Expand Down Expand Up @@ -242,10 +250,11 @@ function loading(p5, fn) {
* Note: When a `.obj` file references materials stored in a `.mtl` file,
* p5.js loads and applies them, so a model with several materials appears the
* way it was exported. Each material can use diffuse (`map_Kd`), specular
* (`map_Ks`), ambient (`map_Ka`), shininess (`map_Ns`), and normal
* (`map_Bump`) texture maps. Keep the `.mtl` file and its images alongside
* the `.obj` file so their paths resolve. A texture that fails to load is
* skipped with a warning instead of failing the whole model.
* (`map_Ks`), ambient (`map_Ka`), shininess (`map_Ns`), bump (`map_Bump`),
* and normal (`norm`) texture maps. A bump map is read as a height map, while
* `norm` is read as a tangent-space normal map. Keep the `.mtl` file and its
* images alongside the `.obj` file so their paths resolve. A texture that
* fails to load is skipped with a warning instead of failing the whole model.
*
* The first way to call `loadModel()` has three optional parameters after the
* file path. The first optional parameter, `successCallback`, is a function
Expand Down Expand Up @@ -839,10 +848,9 @@ function loading(p5, fn) {

// 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.
// only done when a material actually uses one, so plain models pay nothing
const needsTangents = Object.values(materials).some(
m => m && m.normalTexture
m => m && (m.normalTexture || m.bumpTexture)
);
if (needsTangents) {
model.computeTangents();
Expand Down
126 changes: 121 additions & 5 deletions src/webgl/material.js
Original file line number Diff line number Diff line change
Expand Up @@ -2577,10 +2577,17 @@ function material(p5, fn) {
* 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>.
* <a href="#/p5/push">`push()`</a> and <a href="#/p5/pop">`pop()`</a>.
*
* <a href="#/p5/bumpTexture">`bumpTexture()`</a> creates a similar effect from
* a grayscale height map instead. The two are easy to mix up because they
* produce similar results, but they expect different images: a normal map is
* the blue-tinted kind that stores directions, while a bump map is grayscale
* and stores height. Only one can be active at a time, so setting one
* replaces the other.
*
* A light source is needed to see the effect. Models loaded with
* <a href="#/p5/loadModel">loadModel()</a> apply their own normal map from
* <a href="#/p5/loadModel">`loadModel()`</a> apply their own normal map from
* the `.mtl` file's `map_Bump`.
*
* Note: `normalTexture()` can only be used in WebGL mode.
Expand All @@ -2603,7 +2610,9 @@ function material(p5, fn) {
* normalMap.loadPixels();
* for (let y = 0; y < normalMap.height; y += 1) {
* for (let x = 0; x < normalMap.width; x += 1) {
* // Slope of the ridge at this point.
* // Slope of the ridge at this point. The pattern repeats a whole
* // number of times across the image so it tiles, which keeps it from
* // showing a seam where the sphere's texture coordinates wrap around.
* 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;
Expand Down Expand Up @@ -2635,6 +2644,9 @@ function material(p5, fn) {
* noStroke();
* fill(200);
*
* // Tile the map so it wraps around the sphere without a seam.
* textureWrap(REPEAT);
*
* // Add the ridges without changing the geometry.
* normalTexture(normalMap);
* sphere(40);
Expand All @@ -2647,6 +2659,102 @@ function material(p5, fn) {
return this;
};

/**
* Sets a grayscale image that adds bumps and dents to a shape's surface.
*
* A bump map is a height map: the brightness of the image at each point is
* read as how high the surface is there, and p5.js works out which way the
* surface tilts from how quickly that height changes. Bright areas rise and
* dark areas sink, so lights react to detail that isn't in the geometry.
*
* `bumpTexture()` works like <a href="#/p5/texture">`texture()`</a>, but sets
* the bump map instead of the base color. The parameter, `tex`, is the image
* to use. Passing `null` clears it, as in `bumpTexture(null)`. The optional
* second parameter, `scale`, tunes how pronounced the bumps are. The map can
* also be scoped between <a href="#/p5/push">`push()`</a> and
* <a href="#/p5/pop">`pop()`</a>.
*
* <a href="#/p5/normalTexture">`normalTexture()`</a> creates a similar effect
* from a tangent-space normal map, the blue-tinted kind that stores
* directions rather than height. A bump map is usually easier to make by
* hand, since it's just a grayscale picture of where the surface is high and
* low. Only one can be active at a time, so setting one replaces the other.
*
* A light source is needed to see the effect.
*
* Note: `bumpTexture()` can only be used in WebGL mode.
*
* @method bumpTexture
* @param {p5.Image|p5.MediaElement|p5.Graphics|p5.Texture|p5.Framebuffer|p5.FramebufferTexture} tex grayscale image to use as the bump map, or `null` to clear it.
* @param {Number} [scale=1] strength multiplier for the bumps.
* @chainable
*
* @example
* // Click and drag the mouse to view the scene from different angles.
*
* let bumpMap;
*
* function setup() {
* createCanvas(100, 100, WEBGL);
*
* // Build a grayscale height map with a grid of round bumps.
* bumpMap = createImage(64, 64);
* bumpMap.loadPixels();
* for (let y = 0; y < bumpMap.height; y += 1) {
* for (let x = 0; x < bumpMap.width; x += 1) {
* // Bright where the surface is high, dark where it's low. The pattern
* // repeats a whole number of times across the image so it tiles,
* // which keeps it from showing a seam where the sphere's texture
* // coordinates wrap around.
* let h = sin((x / bumpMap.width) * TWO_PI * 4);
* h *= sin((y / bumpMap.height) * TWO_PI * 4);
* let v = (h * 0.5 + 0.5) * 255;
* let i = (x + y * bumpMap.width) * 4;
* bumpMap.pixels[i] = v;
* bumpMap.pixels[i + 1] = v;
* bumpMap.pixels[i + 2] = v;
* bumpMap.pixels[i + 3] = 255;
* }
* }
* bumpMap.updatePixels();
*
* describe('A gray sphere lit from the upper left. A grid of round bumps covers its surface.');
* }
*
* function draw() {
* background(0);
*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add orbitControl() here, and some subtle rotation like in the other PR, + maybe some shininess to make it clearer?

I also notice a seam here too:

Image

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, added orbitControl(), the same subtle rotation, and turned on specular + shininess so the raised areas catch a highlight. reads much clearer.

the seam was the texture not tiling, same root cause as the one on the other pr. two parts to it here: the pattern now repeats a whole number of times across the image, and the example turns on textureWrap(REPEAT). the wrap matters specifically for bump maps because the shader reads a neighbouring texel to get the slope, so at u=1 that neighbour was running off the edge and clamping, which flattened the slope right at the seam. with both, it's gone.

* // Enable orbiting with the mouse.
* orbitControl();
*
* // Rock the shape so the lighting shifts across it.
* rotateY(sin(millis() * 0.002) * PI * 0.1);
*
* // Light the sphere from the upper left.
* ambientLight(60);
* pointLight(255, 255, 255, -80, -80, 150);
* noStroke();
* fill(200);
*
* // Tile the map so it wraps around the sphere without a seam.
* textureWrap(REPEAT);
*
* // Highlights make the raised areas easier to pick out.
* specularMaterial(255);
* shininess(40);
*
* // Raise the bumps without changing the geometry.
* bumpTexture(bumpMap, 4);
* sphere(40);
* }
*/
fn.bumpTexture = function (tex, scale) {
this._assert3d('bumpTexture');
this._renderer.bumpTexture(tex || null, scale);

return this;
};

/**
* Sets an image that controls where a shape looks glossy.
*
Expand Down Expand Up @@ -4135,11 +4243,19 @@ function material(p5, fn) {
this.states.setValue('fillColor', new Color([1, 1, 1]));
};

// normal maps and bump maps share one texture slot and differ only by the mode
// flag the shader reads, so only one can be active at a time and setting either
// replaces the other. null clears the map, back to the plain shader variant.
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);
this.states.setValue('_normalMapMode', 0);
};

Renderer3D.prototype.bumpTexture = function (tex, scale = 1) {
this.states.setValue('_normalTex', tex || null);
this.states.setValue('_normalScale', tex ? scale : 1);
this.states.setValue('_normalMapMode', tex ? 1 : 0);
};

// the remaining map setters mirror _applyPartState: setting a map also turns
Expand Down
5 changes: 3 additions & 2 deletions src/webgl/p5.GeometryPart.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ function createPartState() {
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
normalScale: 1 // map_Bump -bm -> bump strength multiplier
normalTexture: null, // map_Bump or norm -> p5.Image | null
normalScale: 1, // -bm -> strength multiplier
normalMapMode: 0 // 0 = normal map (norm), 1 = bump map (map_Bump)
};
}

Expand Down
18 changes: 16 additions & 2 deletions src/webgl/shaders/phong.frag
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ uniform bool uHasShininessTex;
uniform sampler2D uNormalSampler;
uniform bool uHasNormalMap;
uniform float uNormalScale;
uniform int uNormalMapMode;
uniform vec2 uNormalTexelSize;
#endif

IN vec3 vNormal;
Expand Down Expand Up @@ -71,8 +73,20 @@ void main(void) {
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;
// scale the tangent-space slope so the bump strength can be tuned (-bm)
vec3 mapN;
if (uNormalMapMode == 1) {
// bump map: brightness is height, so the tangent-space normal comes from
// how fast that height changes between neighbouring texels.
float h = TEXTURE(uNormalSampler, vTexCoord).r;
float hu = TEXTURE(uNormalSampler, vTexCoord + vec2(uNormalTexelSize.x, 0.0)).r;
float hv = TEXTURE(uNormalSampler, vTexCoord + vec2(0.0, uNormalTexelSize.y)).r;
// the surface leans away from the direction height increases in
mapN = normalize(vec3(h - hu, h - hv, 1.0));
} else {
// normal map: rgb already holds the tangent-space normal
mapN = TEXTURE(uNormalSampler, vTexCoord).rgb * 2.0 - 1.0;
}
// scale the tangent-space slope so the strength can be tuned (-bm)
mapN.xy *= uNormalScale;
N = normalize(mat3(T, B, N) * mapN);
}
Expand Down
18 changes: 16 additions & 2 deletions src/webgpu/shaders/material.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct MaterialUniforms {
uMetallic: f32,
uHasNormalMap: u32,
uNormalScale: f32,
uNormalMapMode: u32,
uNormalTexelSize: vec2<f32>,
}

// Group 0: Lighting
Expand Down Expand Up @@ -396,8 +398,20 @@ ${useTextureMaps ? ` if (material.uHasNormalMap == 1) {
var T = normalize(input.vTangent.xyz);
T = normalize(T - N * dot(N, T));
let B = cross(N, T) * input.vTangent.w;
var mapN = textureSample(uNormalSampler, uNormalSampler_sampler, input.vTexCoord).rgb * 2.0 - 1.0;
// scale the tangent-space slope so the bump strength can be tuned (-bm)
var mapN: vec3<f32>;
if (material.uNormalMapMode == 1u) {
// bump map: brightness is height, so the tangent-space normal comes from
// how fast that height changes between neighbouring texels.
let h = textureSample(uNormalSampler, uNormalSampler_sampler, input.vTexCoord).r;
let hu = textureSample(uNormalSampler, uNormalSampler_sampler, input.vTexCoord + vec2<f32>(material.uNormalTexelSize.x, 0.0)).r;
let hv = textureSample(uNormalSampler, uNormalSampler_sampler, input.vTexCoord + vec2<f32>(0.0, material.uNormalTexelSize.y)).r;
// the surface leans away from the direction height increases in
mapN = normalize(vec3<f32>(h - hu, h - hv, 1.0));
} else {
// normal map: rgb already holds the tangent-space normal
mapN = textureSample(uNormalSampler, uNormalSampler_sampler, input.vTexCoord).rgb * 2.0 - 1.0;
}
// scale the tangent-space slope so the strength can be tuned (-bm)
mapN = vec3<f32>(mapN.xy * material.uNormalScale, mapN.z);
N = normalize(mat3x3<f32>(T, B, N) * mapN);
}
Expand Down
4 changes: 2 additions & 2 deletions test/unit/assets/bump_sphere.mtl
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ newmtl m0
Kd 0.8 0.8 0.8
Ks 0.5 0.5 0.5
Ns 60
map_Bump spheremap.jpg
map_Bump -bm 8 bumpmap.png

newmtl m1
Kd 0.8 0.8 0.8
Ks 0.5 0.5 0.5
Ns 60
map_Bump spheremap.jpg
map_Bump -bm 8 bumpmap.png
Binary file added test/unit/assets/bumpmap.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion test/unit/assets/normal_mapped.mtl
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
newmtl m0
Kd 0.8 0.8 0.8
map_Bump spheremap.jpg
norm spheremap.jpg

newmtl m1
Kd 0.5 0.5 0.5
2 changes: 2 additions & 0 deletions test/unit/io/loadModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ suite('loadModel', function () {
const normalMapped = model.parts.find(p => p.partState.normalTexture);
assert.ok(normalMapped, 'a part has the normal map');
assert.equal(normalMapped.partState.normalTexture, fakeImage);
// norm means read it as a normal map, not as heights
assert.equal(normalMapped.partState.normalMapMode, 0);
} finally {
delete mockP5Prototype.loadImage;
}
Expand Down
Loading
Loading