Skip to content

GSOC 26: normal (bump) map support - #9067

Open
Nixxx19 wants to merge 11 commits into
processing:mainfrom
Nixxx19:normal-mapping
Open

GSOC 26: normal (bump) map support#9067
Nixxx19 wants to merge 11 commits into
processing:mainfrom
Nixxx19:normal-mapping

Conversation

@Nixxx19

@Nixxx19 Nixxx19 commented Aug 9, 2026

Copy link
Copy Markdown
Member

what

adds normal (bump) map support (map_Bump), the last of the mtl texture maps. a normal-mapped material perturbs the surface normal per pixel so lighting shows surface detail that isn't in the geometry.

note: this is stacked on #9066 (specular/ambient/shininess maps) since it reuses the same per-part texture infrastructure. until #9066 merges, the diff here also shows those changes. the normal-map-specific work is the tangent pipeline below.

how

normal mapping needs a per-vertex tangent basis, which p5 didn't have. the pieces:

  1. p5.Geometry.computeTangents() (new) computes a per-vertex surface tangent from the uvs (edge vectors + uv deltas, accumulated per vertex, Gram-Schmidt orthogonalised against the normal). the bitangent handedness is stored in the tangent's w so the shader can rebuild it as cross(normal, tangent) * w.
  2. the loader computes tangents once on the aggregate (only when a material has a normal map) and buildMaterialParts hands each part its slice, mirroring how normals flow.
  3. a new aTangent vertex attribute (defaults to a dummy so it's always valid; the shader only uses it when a normal map is bound).
  4. phong.vert transforms the tangent into view space alongside the normal and passes it on.
  5. phong.frag rebuilds the TBN basis, samples the normal map, and perturbs the normal, gated by uHasNormalMap so untextured draws are unaffected.

zero regression

the aTangent attribute and shader branch are gated/defaulted so existing 3D draws are untouched. confirmed by the full visual + unit suite staying green.

testing

  • computeTangents() unit test with a known triangle (asserts the exact tangent vector + handedness), plus the no-uvs case
  • loadModel test: a normal-mapped obj computes tangents, and the part carries both the normal map and its own per-vertex tangents
  • mtlToPartState unit test for the normal map field
  • a visual regression test rendering a normal-mapped model under a point light
  • full unit + visual suite green

part of the gsoc multi-material .mtl work, follows #8879, #8955, #9063, and #9066.

@davepagurek

Copy link
Copy Markdown
Contributor

Like the previous PR, I have some performance concerns about this happening all the time so I'd like to understand what the impact is. This one in particular seems like it adds extra data to geometry draws, which I imagine would have a noticeable impact on heavier sketches.

Like my suggestion in the previous PR, it could be that we want to actually exclude some of this functionality from the shader if it's not used (with a separate shader, either separate source code or the same code + different #defines to change functionality). Another thing that we could consider if adding extra vertex data is too expensive is to use screen-space tangents in the pixel shader (we could get u/v vectors with dFdx and dFdy in WebGL 2.) Not sure how much, if any, of that is necessary, but it would be good to be able to check on a few different devices if you have e.g. two web editor sketches that can be compared that we can send to different people on different hardware.

One last thing, I think maybe the visual test example here could be clearer. Is it possible to use something like a sphere with a bump map and some lighting to make it closer to what the intended effect is that the end user would want?

@davepagurek

Copy link
Copy Markdown
Contributor

One more request: should we add an option for bump height too, following the pattern threejs uses? I think it's relatively common for bump maps to use as much range as they can, going from black to white, to get the best precision they can on normals, and then in software scale it down. I see on Wikipedia there's a -bm mult_value that we could hook up to that in the obj format.

Should we also make methods to be able to set these similar to texture()? Like bumpTexture()? I don't think we currently have a good way to unset one of these, possibly we could support bumpTexture(null), or just make you use it between push/pop to scope it.

@Nixxx19

Nixxx19 commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

update, switched normal mapping to baked per vertex tangents and got webgpu working too, plus added bump strength and a bumpTexture() method.

screen space tangents were coming out faceted on low poly meshes (dFdx of the position is flat per triangle), so baked fixes that and keeps the surface smooth. its all behind the two shader variant so plain lit materials get none of the tangent or normal map code, and tangents are only computed/uploaded for meshes that actually have a normal map, so no bump map scenes pay nothing extra.

on perf, baked vs screen space came out about equal, on my machine a heavy scene of 121 lit bump mapped spheres ran ~50.1 fps screen space vs ~49.5 baked, and it held up equal across a couple other machines too so no regression from going baked.

also ported it to webgpu. no preprocessor in wgsl so the shaders are functions of a flag now, the maps variant pulls in the tangent + normal sampling and the plain one leaves it out.

added the -bm bump strength from the mtl so intensity is tunable, and a bumpTexture() method so you can set a normal map in code like texture(). bumpTexture(null) clears it and it scopes with push/pop, and it builds tangents on the fly for shapes that dont have their own.

both renderers render a lit bump mapped sphere and tests pass on both. stacked on #9066 so that one goes in first

Comment thread src/webgl/material.js Outdated
*
* @method bumpTexture
* @param {p5.Image|p5.MediaElement|p5.Graphics|p5.Texture|p5.Framebuffer|p5.FramebufferTexture} tex normal map, or `null` to clear it.
* @param {Number} [scale] bump strength multiplier. Defaults to 1.

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.

We can add the default value right into the jsdoc too:

Suggested change
* @param {Number} [scale] bump strength multiplier. Defaults to 1.
* @param {Number} [scale=1] bump strength multiplier. Defaults to 1.

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 [scale=1]. i also renamed the method and rewrote this whole doc block, more on that in the thread just below

this,
materialVertexShader,
materialFragmentShader,
materialVertexShader({ useTextureMaps }),

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.

nice!

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.

thanks! the reflection setup made it fall into place once the wgsl declared the tangent attribute + normal texture

Comment thread src/webgl/material.js Outdated
* @param {Number} [scale] bump strength multiplier. Defaults to 1.
* @chainable
*/
fn.bumpTexture = function (tex, scale) {

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.

Similar to this, should we make methods for the shininess texture and the other texture properties?

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.

good call, added specularTexture(), ambientTexture() and shininessTexture() too. they mirror this one, set the map and turn on the term it modulates, and null clears it. added tests for all four in p5.RendererGL.js

Comment thread src/webgl/material.js Outdated
* Sets a normal (bump) map to add surface detail to shapes under lighting.
*
* `bumpTexture()` works like <a href="#/p5/texture">texture()</a>, but for a
* tangent-space normal map. Call it before drawing a shape and its surface

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.

We should probably mention that we read the brightness of the texture to determine bump height, and call it a bump map rather than a normal map, since those are different.

Would be good to add an example here too.

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.

so this one is actually a normal map, not a bump map, we decode the rgb as the tangent space normal (rgb*2-1), we dont read brightness. since bump and normal maps are different like you said, i renamed the method to normalTexture() to be honest about what it is (and its the same kind gltf uses). rewrote the doc to say that and added an example.

@davepagurek

Copy link
Copy Markdown
Contributor

One last thought: it looks like the gltf format supports normal maps instead of bump maps. I'm sort of on the fence about what to do about that, because bump maps feel much easier to create generatively and are conceptually a lot easier to understand. But to fully support a gltf import, we'd want access to normal maps. We could have both available in the future, but also they can't both be active at once -- if we add something like normalTexture() in addition to bumpTexture(), it'd probably have to set the same texture but change a flag for how we interpret that texture. We don't have to do both in order to merge this PR, but we should maybe double check that we won't have to change any of our current APIs if we also add normal maps later. Do you think normalTexture to set the texture + an internal flag works, or do you have other ideas we should consider?

@Nixxx19

Nixxx19 commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

on the gltf / bump vs normal question, one clarification first: our current implementation is already a normal map, we decode the rgb as the tangent space normal (rgb*2-1) and dont read brightness, so its the gltf compatible path. i renamed the public method to normalTexture() in this pr to reflect that (and it lines up with the internal _normalTex / normalTexture partState we already had).

on future proofing, yeah i think one texture slot + an interpretation flag is the clean way, exactly like you suggested. a future bumpTexture() (the brightness -> gradient kind, which is the easier to author one you mentioned) would set the same _normalTex but flip a mode flag, and the shader would branch on it. only one active at a time, and adding it later is purely additive, so we shouldnt have to change normalTexture() or any current api. happy to take that on as a follow up once this lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants