Advertisement

Bump map to normal map

Started by March 29, 2022 07:23 AM
13 comments, last by JoeJ 2 years, 10 months ago

Thanks. I'll wait for that.

The use case is that, as usual, I'm writing a client for Second Life/Open Simulator, which has both legacy bump maps and newer normal maps. The old SL renderer uses a different shader for bump maps for historical reasons. I want to convert them into texture maps once, when loaded, and use the same render pipeline as for everything else.

JoeJ said:
You get higher quality from a larger kernel at the cost of loosing high frequencies, proven from the rounding of the tiles at the bottom image. Notice this rounding does not agree with the grid image on the right, which is just black and white.

Which is why I said you can do a weighted combination of derivatives at different scales to get the desired look for each texture. You can choose to weight the 1-pixel scale derivative higher than others to emphasize the small details, or use the bigger derivatives to emphasize the 3D shape of the bump map. In my implementation I control this with a “balance” parameter that adjusts the weight between fine details and big features, using several different derivative octaves (e.g. 1px, 2px, 4px, 8px, 16px, 32px, etc.). The images I posed above were generated with equal balance of low and high frequencies.

Advertisement

@aressera I agree your proposal is better in practice for normal maps, as it allows to tweak for the best compromise. 
But for other applications going with simple and fast gradients is the better option, and i got a bit ignorant while focusing on the problem to do this as good as possible. Sorry for that. ; )

I did the test code, and found two surprises, lacking an explanation for either of them.
Here's what gave me best results:

The red color shows error of direct neighbors only (E), green is error for diagonals (V), and blue is best case for combining both (C). Accumulated angle error numbers printed on the right.
So we see using both reduces the error to one half, and errors are noticeable mostly on the edge of the sphere at steep angles.

The confusing finds:

  1. I got clearly best results with a weight of ⅔ for the diagonals, not ½ as i had assumed.
  2. To make magnitudes of diagonal gradients similar to direct neighbor gradients, i had to multiply with 0.5, not 1/sqrt(2)

That's interesting. Now i'll have to do the same test in 3D too, but then i can probably improve the quality of my fluid simulations by using different weights… : )

I have now added normals calculation, giving no further surprises.

Error is smaller by summing up weighted cross products than from summing normalized cross products.
For squared error it's the opposite, but the difference is tiny, and it looks like erroneous normals at the discontinuous boundary are the cause.

So that's the most accurate normals from a height map i can get without any filtering.
(Removing code from previous post.)

 

		static bool visGradientTest = 1; ImGui::Checkbox("visGradientTest", &visGradientTest);
		if (visGradientTest)
		{
			constexpr int iRes = 1024;
			constexpr int msRes = iRes/16;
			static std::vector<float> heightMap (iRes * iRes, 0);
			static std::vector<float> temp (iRes * iRes / 4, 0);
			static std::vector<float> msHeightMap (iRes * iRes / 16, 0);
			static bool init = true;
			if (init)
			{
				// make sphere image
				for (int y=0; y<iRes; y++)
				for (int x=0; x<iRes; x++)
				{
					float dx = (float(x)+.5f) / float(iRes);
					float dy = (float(y)+.5f) / float(iRes);
					float d2 = dx*dx + dy*dy;
					float h = sqrt(max(0.f, 1.f - d2));

					int i = y*iRes + x;
					heightMap[i] = h;
				}

				// make downscaled image, so height is area integrated per pixel

				int dRes = iRes;
				int size[] = {dRes, dRes};
				TextureFilter::DownSampleTexture (temp.data(), heightMap.data(), size);
				size[0]>>=1; size[1]>>=1;
				TextureFilter::DownSampleTexture (msHeightMap.data(), temp.data(), size);
				size[0]>>=1; size[1]>>=1;
				TextureFilter::DownSampleTexture (temp.data(), msHeightMap.data(), size);
				size[0]>>=1; size[1]>>=1;
				TextureFilter::DownSampleTexture (msHeightMap.data(), temp.data(), size);

				init = false;
			}

			if (0) // vis hm
			{
				for (int y=0; y<iRes; y+=16)
				for (int x=0; x<iRes; x+=16)
				{
					float dx = (float(x)+.5f) / float(iRes);
					float dy = (float(y)+.5f) / float(iRes);
					int i = y*iRes + x;
					float h = heightMap[i];
					RenderPoint(vec(dx,dy,h), 1,0,0);
					RenderLine(vec(dx,dy,0), vec(dx,dy,h), dx,dy,0);
				}
			}

			if (0) // vis ms hm
			{
				for (int y=0; y<msRes; y++)
				for (int x=0; x<msRes; x++)
				{
					float dx = (float(x)+.5f) / float(msRes);
					float dy = (float(y)+.5f) / float(msRes);
					int i = y*msRes + x;
					float h = msHeightMap[i];
					RenderPoint(vec(dx,dy,h), 1,0,0);
					RenderLine(vec(dx,dy,0), vec(dx,dy,h), dx,dy,0);
				}
			}

			static float visErrorScl = 15000.f; ImGui::DragFloat("visErrorScl", &visErrorScl);
			static float diagonalWeight = /*.5f*/2.f/3.f; ImGui::DragFloat("diagonalWeight", &diagonalWeight, 0.01f);
			static float compDiagLen = .5f; ImGui::DragFloat("compDiagLen", &compDiagLen, 0.01f);
			static bool preNormalize = false; ImGui::Checkbox("preNormalize", &preNormalize);


			// calc gradients and normals
			float magSumE = 0;
			float magSumV = 0;
			float errSumE = 0;
			float errSumV = 0;
			float errSumC = 0;

			float normMagSumE = 0;
			float normMagSumV = 0;
			float errSumNormE = 0;
			float errSumNormV = 0;
			float errSumNormC = 0;
			float errSumNormC2 = 0;

			{
				const float c = 1.f / float(msRes);

				for (int y=1; y<msRes-1; y++)
				for (int x=1; x<msRes-1; x++)
				{
					float dx = (float(x)+.5f) / float(msRes);
					float dy = (float(y)+.5f) / float(msRes);

					// direct neighbors grad
					vec2 eG(0);
					float h0 = msHeightMap[(y+0)*msRes + (x+1)];
					float h1 = msHeightMap[(y+0)*msRes + (x-1)];
					float h2 = msHeightMap[(y+1)*msRes + (x+0)];
					float h3 = msHeightMap[(y-1)*msRes + (x+0)];
					eG = vec2(h1 - h0, h3 - h2);

					// direct neighbors norm
					vec p0 (dx+c, dy, h0); 
					vec p1 (dx-c, dy, h1); 
					vec p2 (dx, dy+c, h2); 
					vec p3 (dx, dy-c, h3); 
					vec normE = vec(p1 - p0).Cross(p3 - p2);

					// diagonal neighbors grad
					vec2 vG(0);
					h0 = msHeightMap[(y+1)*msRes + (x+1)];
					h1 = msHeightMap[(y+1)*msRes + (x-1)];
					h2 = msHeightMap[(y-1)*msRes + (x-1)];
					h3 = msHeightMap[(y-1)*msRes + (x+1)];
					vG =	vec2(1, 1) * (h2 - h0) + 
							vec2(1,-1) * (h1 - h3);
					vG *= compDiagLen; // compensate for larger distance of diagonal samples; 0.5 = (1/sqrt(2)) * (1/sqrt(2))

					// diagonal neighbors norm
					p0 = vec (dx+c, dy+c, h0); 
					p1 = vec (dx-c, dy+c, h1); 
					p2 = vec (dx-c, dy-c, h2); 
					p3 = vec (dx+c, dy-c, h3); 
					vec normV = vec(p1 - p3).Cross(p2 - p0);
					normV *= compDiagLen;

					// combined gradient
					vec2 cG = (eG + vG * diagonalWeight) / 2;

					// combined normal
					vec normal = (preNormalize
						? vec(normE.Unit() + normV.Unit()).Unit()
						: vec(normE + normV * diagonalWeight).Unit());



					// verify stuff

					std::complex<float> cRef(dx, dy);
					std::complex<float> cEg(eG[0], eG[1]);
					std::complex<float> cVg(vG[0], vG[1]);
					std::complex<float> cCg(cG[0], cG[1]);
					float eG_angleError = fabs(std::arg(cRef / cEg));
					float vG_angleError = fabs(std::arg(cRef / cVg));
					float cG_angleError = fabs(std::arg(cRef / cCg));

					magSumE += eG.Length();
					magSumV += vG.Length();
					if (!std::isnan(eG_angleError)) errSumE += eG_angleError;
					if (!std::isnan(vG_angleError)) errSumV += vG_angleError;
					if (!std::isnan(cG_angleError)) errSumC += cG_angleError;


					vec p (dx, dy, msHeightMap[y*msRes + x]); 
					vec refN = p.Unit();

					normMagSumE += normE.Length();
					normMagSumV += normV.Length();
					float eN_angleError = acos(refN.Dot(normE.Unit()));
					float vN_angleError = acos(refN.Dot(normV.Unit()));
					float cN_angleError = acos(refN.Dot(normal));
					if (msHeightMap[y*msRes + x] > 0.05f)
					{
						if (!std::isnan(eN_angleError)) errSumNormE += eN_angleError;
						if (!std::isnan(vN_angleError)) errSumNormV += vN_angleError;
						if (!std::isnan(cN_angleError)) errSumNormC += cN_angleError;
						if (!std::isnan(cN_angleError)) errSumNormC2 += cN_angleError * cN_angleError;

						if (std::arg(std::pow(cRef,8.0f)) > 0)
						{
							RenderArrow(vec(dx,dy,0), cG / float(msRes*2) * 50, 1.f / (msRes*8), 0,0,min(1, cG_angleError * visErrorScl));
							RenderArrow(p, normal*-1.f, 1.f / (msRes*8), 0,0,min(1, vN_angleError * visErrorScl));
						}
						else if (dx>dy)
						{
							RenderArrow(vec(dx,dy,0), eG / float(msRes*2) * 50, 1.f / (msRes*8), min(1, eG_angleError * visErrorScl),0,0);
							RenderArrow(p, normE.Unit()*-1.f, 1.f / (msRes*8), min(1, eN_angleError * visErrorScl),0,0);
						}
						else
						{
							RenderArrow(vec(dx,dy,0), vG / float(msRes*2) * 50, 1.f / (msRes*8), 0,min(1, vG_angleError * visErrorScl),0);
							RenderArrow(p, normV.Unit()*-1.f, 1.f / (msRes*8), 0,min(1, vN_angleError * visErrorScl),0);
						}
						//RenderArrow(p, refN.Unit()*-1.f, 1.f / (msRes*8), 1,1,1);
					}

				}
				ImGui::Text("magSumE %f  magSumV %f", magSumE, magSumV);
				ImGui::Text("errSumE %f  errSumV %f  errSumC %f", errSumE, errSumV, errSumC);
				ImGui::Text("normMagSumE %f  normMagSumV %f", normMagSumE, normMagSumV);
				ImGui::Text("errSumNE %f  errSumNV %f  errSumNC %f  errSumNC2 %f", errSumNormE, errSumNormV, errSumNormC, errSumNormC2);
			}
		}

Edit: Changed the code to do error measurement only if the sample is on the sphere, otherwise the reference normal is wrong and we got big error numbers.
Now the squared error also agrees with not pre-normalizing.

 

This topic is closed to new replies.

Advertisement