Skip to content

[Bugfix] Shade materials the way their values say: normal maps, emission, reflections - #1292

Merged
untoldengine merged 9 commits into
untoldengine:developfrom
miolabs:bugfix/material_shading
Oct 3, 2026
Merged

untoldengine merged 9 commits into
untoldengine:developfrom
miolabs:bugfix/material_shading

Conversation

@miogds

@miogds miogds commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

A building exported from Blender looked wrong in the engine in ways that were not missing features. Rendering each object alone in Blender (Cycles) and in the engine, with the same camera, environment map and sun, turned up eight defects in how materials are shaded and loaded. Each is fixed in its own commit, with render tests that fail without it. The last commit holds the rebuilt shader libraries and the reference images that change.

Before and after

Blender on the left, develop in the middle, this branch on the right. Same cooked files in both engine columns.

Four test spheres: mirror, rough metal, matte white, black lacquer.

Test spheres: Blender, engine before, engine after

Roughness from 0.05 to 1, left to right, on a metal and on white, red and black non-metals, under two environment maps. Before, every metal is a mirror, the polished ones are the darkest, and no non-metal reflects anything.

Roughness sweep: engine before, engine after

Objects of the building. The ovens are black enamel (drawn white), the vending machines and the kiosk are brushed metal (drawn as chrome), the kiosk's windows glow.

Building objects 1: Blender, engine before, engine after

Building objects 2: Blender, engine before, engine after

What still differs in these pictures is not in this pull request: glass (the engine has no transmission), the inside of the ovens and of the vending machines seen through their glass (nothing occludes the environment light), and the frame as a whole, which the engine develops brighter than Blender's Standard view.

Cause and change, by commit

  1. Normal maps were tilted, and the normal strength was not applied. A normal stored as RGB was normalized before the remap from [0, 1] to [-1, 1], which shortens it first: a flat texel came out 22° off. normalScale, which a .untold material carries (Blender's Normal Map Strength), was read and never used. The remap now comes first, and applyNormalStrength mixes the map with the surface's own normal. normalScale is part of the batching hash.
  2. A black base color was drawn white. A base color factor of zero counted as "not set". It now does only for a textured material.
  3. Emissive textures were never drawn, and blended materials gave off no light. The texture was loaded and no pass bound it; the transparency pass added no emission. Both passes now draw the factor times the texture. A material read from a USD file, which has the texture and no factor, gets a white factor. The emissive color and texture are part of the batching hash.
  4. Rough surfaces reflected the environment as sharply as a mirror. The environment map is filtered once per roughness into its mip levels, and the sampler that reads it had mip_filter::none, so level 0 was always read. The environment has its own sampler now; the lookup table keeps the clamped one.
  5. The reflection table was read upside down. It was written with roughness = 1 − row and read with row = roughness. A polished metal took the share of a rough one (36 % of the environment) and the reverse. The table is now written the way it is read, in the equirect bake and in the XR probe's.
  6. A rough metal keeps the light it bounces more than once. With 5 fixed, the table's single bounce makes the roughest white metal give back a third of the light. The later bounces are added back (Kulla and Conty's approximation, as Filament applies it), which also keeps rough metals as bright as they have been.
  7. Non-metals did not reflect the environment. The ambient term was mix(diffuse, specular, metallic). Every surface now reflects (4 % head on for a non-metal, more at a slant, whichever side is seen) and its diffuse part gives up that share, so a white surface gives back all the light of an even environment whatever its finish. The reflection takes F0, as the table expects, not the Fresnel value at the angle of view.
  8. A grayscale color texture was read as linear. The engine expands a one-channel image to RGBA before loading it, through MTKTextureLoader.newTexture(cgImage:), which ignores .SRGB. A mid gray came out 2.3 times as bright as the same gray in three channels. The expanded texture is now an sRGB view; the loader and the texture streaming system share one function for it.

What changes on screen

  • Scenes lit by an environment: polished metals are brighter, rough metals blurred, non-metals gain a reflection. Twelve reference images are regenerated for this: BRDFIBL, LightPassColor, DisplacementMap, TransparencyTarget, Bloom, ChromaticAberration, ColorGrading, DepthOfField, FXAA, MSAA, SMAA, Vignette.
  • Materials with a normal map: the relief is as authored, not leaning.

Verification

  • 25 new render tests: NormalMapShadingTests (3), BaseColorShadingTests (6), EmissiveShadingTests (5), EnvironmentReflectionShadingTests (9), and two in StaticBatchingTest. The reflection tests light a sphere with an environment of one brightness, so the expected values are exact: a white surface of any finish must give back what a matte white one does.
  • The render sweep of the CI job, run locally: 1,155 tests, none failed. UntoldEngineTests (1,533), UntoldComponentKitTests (59) and the remote stream flythrough pass.
  • swift build with -strict-concurrency=complete: no warnings.
  • The shader libraries are built with buildkernels.sh.

Javier Segura added 9 commits October 3, 2026 14:33
…l strength

The model and transparency shaders normalized a normal map's stored color
before remapping it from [0, 1] to [-1, 1]. That shortens the color first,
so every decoded normal came out tilted: a flat texel, (0.5, 0.5, 1), gave
(-0.18, -0.18, 0.63), about 22 degrees off the surface. A neutral normal
map lit a face a third brighter than no normal map at all. Maps packed by
texbake (X and Y only) were not affected.

The remap now comes first and the normalization after.

The material's normal strength was also read from .untold files and then
dropped. Material carries it now (normalScale) and the shaders mix the
mapped normal with the surface's own by it, as Blender's Normal Map node
does. Static batching keeps materials of different strengths apart.
The model and transparency shaders took a base color factor of zero for
"not set" and replaced it with white, whether the material had a base color
texture or not. A material that is simply black (a black oven, a screen
that is off, black rubber) was drawn white.

Zero now means "untinted" only when there is a texture to show; without
one it is the color it says.
A material's emissive texture was loaded and never bound: a lit sign showed
the flat color of its emissive factor in place of what is painted on it. The
model pass now draws the factor times the texture, as glTF defines it, and the
transparency pass, which drew no emission at all, does the same.

A material read from a USD file carries the texture without a factor, so its
factor is white when it has one.

The emissive color and texture are now part of what tells two materials apart
for batching: two lamps alike in all but their glow could share a batch.
The environment map is filtered once per roughness when it is loaded: one mip
level per roughness, from a mirror image to the widest blur. The sampler that
reads it had no mip filter, so the level asked for was ignored and every
surface reflected level 0: a rough metal showed the room as sharply as a
mirror.

The environment now has its own sampler, with a mip filter, that wraps around
the horizon and stops at the poles. The lookup table keeps the clamped sampler
that fixed the white spot.
The table that tells how much of the environment a surface reflects was
written with its rows running from a roughness of 1 down to 0, and read with
the roughness as the row: a polished surface took the share of a rough one
and the reverse. A polished metal reflected about a third of the environment,
so its reflections came out dark, and a rough one all of it.

The rows now run from 0 to 1, the way specularIBL reads them, in the table of
the environment map and in the one of the XR environment probe.

The test lights a sphere with an environment that is equally bright all
around: a polished white metal then gives back as much light as a matte white
surface, where it gave back 36 % of it.
The reflection table counts one bounce of light on the surface. A rough
surface bounces part of the light between its own bumps before it lets it go,
and the table takes that part for lost: the roughest white metal gave back a
third of the light around it, and a satin one 89 %.

The environment reflection now adds the later bounces back, each tinted again
by the surface (Kulla and Conty's approximation, in the form Filament uses).
In an environment equally bright all around, a white metal of any roughness
gives back all the light, and a colored one comes out deeper in color, not
brighter than its color allows.

With the table read upside down a rough metal took the share of a polished
one, so this keeps rough metals as bright as they have been.
The environment light was the diffuse part for a non-metal and the reflection
for a metal, blended by the metallic value. A non-metal reflected nothing:
polished plastic, glass and lacquer came out matte, and a black one came out
with no light on it at all.

Every surface now reflects the environment: 4 % of it head on for a non-metal,
more when it is seen at a slant. What it reflects it does not scatter, so the
diffuse part gives up that share and a white surface still gives back all the
light around it and no more, whatever its finish. A metal scatters nothing, as
before.

The reflection takes the reflectance head on (F0), as the lookup table
expects, in place of the Fresnel value at the angle of view, which counted the
slant twice. The share a surface reflects is now one function,
environmentReflectance, that both the reflection and the diffuse part read.

The angle of view counts whichever side of the surface is seen. Both faces of
a surface are drawn, and one seen from behind was taken for one seen edge on,
where a smooth surface reflects nearly everything: the far wall of a glass
object would have shone through its front.
A color texture whose red, green and blue are the same is stored with one
channel. The engine draws it into an RGBA image before it loads it, so that
the shaders do not show it red, and loads that image with
MTKTextureLoader.newTexture(cgImage:), which ignores the .SRGB option: the
texture came back as rgba8Unorm and its sRGB values were read as linear ones.
A mid gray came out 2.3 times as bright as the same gray stored with three
channels, so a gray metal whose base color is such a texture reflected twice
the light it should.

The expanded texture is now handed back as an sRGB view when it holds colors,
with its smaller mip levels built again through the view. Textures of values
(roughness, metallic, height) are read as stored, as before.

The material loader and the texture streaming system each had a copy of the
expansion: both now call loadGrayscaleTextureAsRGBA.
The shader libraries for every platform, built with buildkernels.sh from the
shaders of the commits before this one.

Twelve reference images change with the shading: the reflection table itself
(BRDFIBL), and the scenes that show a metal sphere under the environment
(LightPassColor, DisplacementMap, TransparencyTarget and the post-effect
targets Bloom, ChromaticAberration, ColorGrading, DepthOfField, FXAA, MSAA,
SMAA, Vignette). Written by the reference generators of RendererTests and
PostFXTests.
@untoldengine

Copy link
Copy Markdown
Owner

Nice detective work tracking all eight of these down from Blender comparisons — the before/after images make it really easy to see what each fix does, and the exact-value IBL tests are a great way to pin down BRDF behavior.

One small thing I noticed in computeIBLContribution (LightShader.metal): the diffuse term is attenuated by the full multi-bounce-compensated reflected value (the one that includes the Kulla/Conty laterBounces factor), rather than the raw single-bounce reflectance (reflectedInOneBounce). Physically, the energy "taken from" diffuse should be the single-bounce specular share at that angle — the multi-bounce compensation is about recovering energy that was lost by the specular term's own approximation, not about how much the surface diverts away from diffuse scattering.

In practice this mostly washes out here since F0 = 0.04 for non-metals keeps laterBounces close to 1, and pure metals have (1 - metallic) = 0 anyway so it doesn't touch their diffuse term — which is probably why none of the new tests caught it. But wanted to flag it in case it was an intentional simplification vs. an oversight, since it's the one spot where the math diverges from strict energy conservation. Not blocking either way — happy to approve as is.

@untoldengine
untoldengine merged commit cb53996 into untoldengine:develop Oct 3, 2026
4 checks passed
@miogds
miogds deleted the bugfix/material_shading branch October 5, 2026 13:15
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