Repository navigation
[Bugfix] Shade materials the way their values say: normal maps, emission, reflections - #1292
Conversation
…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.
|
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 In practice this mostly washes out here since F0 = 0.04 for non-metals keeps |
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,
developin the middle, this branch on the right. Same cooked files in both engine columns.Four test spheres: mirror, rough metal, matte white, black lacquer.
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.
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.
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
normalScale, which a.untoldmaterial carries (Blender's Normal Map Strength), was read and never used. The remap now comes first, andapplyNormalStrengthmixes the map with the surface's own normal.normalScaleis part of the batching hash.mip_filter::none, so level 0 was always read. The environment has its own sampler now; the lookup table keeps the clamped one.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.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
BRDFIBL,LightPassColor,DisplacementMap,TransparencyTarget,Bloom,ChromaticAberration,ColorGrading,DepthOfField,FXAA,MSAA,SMAA,Vignette.Verification
NormalMapShadingTests(3),BaseColorShadingTests(6),EmissiveShadingTests(5),EnvironmentReflectionShadingTests(9), and two inStaticBatchingTest. 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.UntoldEngineTests(1,533),UntoldComponentKitTests(59) and the remote stream flythrough pass.swift buildwith-strict-concurrency=complete: no warnings.buildkernels.sh.