DEV Community

Ibukun Sanni
Ibukun Sanni

Posted on

Apparently, Bland 2D Grey Squares Are Important to My 3D Renderer

The white furnace test: like the Chūnin exams, handing in a blank page is how you pass.

A flat grey square.


A furnace render: an albedo-1 sphere inside a uniform environment of radiance 0.5.

There’s a sphere in that picture. Can you see it? No, you can’t. If you could, my renderer would be wrong, and I wouldn’t like that.

What is the furnace test?

Put a sphere of albedo 1 (a surface that absorbs nothing) inside an environment of radiance LL in every direction. Every camera ray either misses the sphere or hits it.

  • PP : a miss reads LL . It escapes straight to the environment.
  • QQ : a hit bounces until it misses, so it reads LL too. It keeps everything on the way, 1×L=L1 \times L = L .
  • RR : the square is flat grey. Every pixel reads LL , so the sphere has no edge.
P∧Q⇒R P \wedge Q \Rightarrow R

A diagram: a camera on the left, a grey sphere on the right with light arriving from all around it, and two orange rays, one missing the sphere and one bouncing off it.


Two camera rays in the furnace. One misses the sphere and reads the environment, L. The other hits the sphere, keeps all of its energy, bounces, and escapes to the same environment: 1 × L = L. Both pixels read L, so the sphere has no edge.

The test runs it backwards. If I can see the sphere, RR is false, so PP or QQ is. PP can't be, so QQ is: the renderer is losing energy if the sphere is darker, and making it if the sphere is brighter. If the furnace test is working, you see nothing. Naruto would be proud.

To see a real-world example of this, I highly recommend Julien Guertault's "The white furnace test". It opens with a ski-slope photo that matches what RR predicts.

One place to look

In my renderer, every material has the same three functions, the same split as pbrt's BxDF (f, PDF, Sample_f):

// How much light goes from `out` to `view_dir`: the BRDF, f_r.
glm::vec3 Eval(const glm::vec3& view_dir, const HitRecord& hit,
               const glm::vec3& out) const;
// The density Sample() draws `out` with.
float Pdf(const glm::vec3& view_dir, const HitRecord& hit,
          const glm::vec3& out) const;
// Draw a direction, and report its pdf and BRDF.
glm::vec3 Sample(Rng& rng, const glm::vec3& view_dir, const HitRecord& hit,
                 float* pdf_out, glm::vec3* brdf_out) const;
Enter fullscreen mode Exit fullscreen mode

Each bounce, the renderer multiplies the ray's energy by one number built from them:

w=fr cos⁡θp w = \frac{f_r\,\cos\theta}{p}
  • ww : the weight, the fraction of the ray's energy that survives the bounce.
  • frf_r : the BRDF, Eval(). How much light the surface sends from the new direction toward the old one.
  • cos⁡θ\cos\theta : the cosine of the angle between the new direction and the surface normal. Light arriving at a slant is spread over more surface.
  • pp : the pdf, Pdf(). How likely Sample() was to pick that direction.

That's the only way a material touches a ray, so it's where every energy bug lives. For a white diffuse surface, ww should be exactly 1.

What failing looks like

Seeing a correct test isn't enough, because a correct test shows literally nothing. So I broke the diffuse material to see what failure looks like:

Four grey squares. The first is flat. The second has a darker disc, the third a white disc. The fourth is solid white.


The furnace with a correct material, and with two classic bugs. Counting the cosine twice makes the weight ρ·cos, which averages 2/3, so the sphere drops from 128 to 85. Forgetting the 1/π makes the weight πρ, and the sphere clips to white. At radiance 1, that same bug is invisible.

A cosine counted twice makes ww average 2/3, so the sphere should read 128 × 2/3 = 85. The render says 85.

A forgotten 1/π makes w=πw = \pi , so the sphere should read > 255. A byte stops at 255, so it clips to white.

The last panel is the same bug at full brightness. The background is already 255, the sphere clips to 255 too, and the bug hides. That's why I render at half brightness, where the target is 128 with room on both sides. Bland 2D Grey Squares Are Important to My 3D Renderer

Every material gets the same test

Nothing in PP or QQ says what the sphere is made of, so the same scene checks every material.

Three grey squares. The first is flat, the second has a faint dark ring, the third a barely visible speckled ring.


The same furnace with three other materials. The mirror vanishes exactly. The rough metal leaves a dark ring at its silhouette. The glass vanishes on average, with a few noisy pixels at its rim.

  • Mirror: vanishes exactly. It sends all the light one way, and every way looks the same.
  • Rough metal: a dark rim, and that's correct. At grazing angles some reflections get nudged into the surface and are absorbed. It can lose energy but never make any, so its test is "nothing brighter than 128".
  • Glass: vanishes on average (127.9). Its radiance goes up by η² going in and back down coming out, so the two cancel.

Limits of the furnace

It only measures how much light comes back, not where it went. A material that scatters the right amount in the wrong directions still vanishes. I found that out with a hexagon-shaped sampler in a previous post.

What I do now

  • Render the furnace for every new material. One scene, a few seconds.
  • Render it below the clip, or a too-bright bug hides.
  • Predict the failure. If the math says 85, the render should too.

Just for fun: the Byakugan (because of all the Naruto references)

A pale lavender eye with no clear pupil and faint veins, almost vanishing into a white background.


The furnace at full brightness with an eye painted on the sphere.

References


From a path tracer I'm building in C++.

Top comments (0)