Thanks for the 3.10.6 fix for swizzled normal maps ( https://feedback.vrchat.com/sdk-bug-reports/p/dpid-mip-mapping-breaks-normal-maps-if-swizzled ). On PC that case looks good in my tests now. However, normal maps
without
swizzle or Flip Green still seem to get broken or degraded mips, because DPID regenerates them from the raw PNG and discards the encoding that Unity's importer has already applied.
A note on how this was put together: I read the SDK source in the 3.10.5 and 3.10.6 packages and ran a small test project, with help from AI assistants (Claude Opus, Claude Fable and GPT Astra) over several rounds of cross-review. I have tried to check every claim against code or measurements, but some details may still be off. Please treat this as a starting point rather than a definitive analysis.
What I am seeing
  1. PNG alpha leaks into X (PC, new in 3.10.6).
    For PNG normal maps whose alpha channel is not fully opaque, the decoded X at every mip (mip 0 included) comes out as (X + 1) × alpha − 1 instead of X (alpha in [0, 1]), so normals lean toward −X. With alpha 128, X drops by about 0.5 at X = 0 and by about 1.0 at X = +1. With alpha 0 the result is X = −1 everywhere. This affects formats that keep alpha (RGBA32, DXT5 and BC7 in my tests); BC5 did not show it. 3.10.5 overwrote alpha with 1, which hid this. In my project, 54 of 1,424 DPID-target PNG normal maps (mostly from purchased outfits) have non-opaque alpha, and 29 of those contain fully transparent pixels.
  2. DXT5 quality loss (PC, 3.10.5 and 3.10.6).
    With the default Normal Quality compression, the mips (mip 0 included) show noticeably higher compression error than without DPID, at least on the detailed test texture below.
  3. Z forced to 1 (Android, 3.10.5 and 3.10.6).
    Every mip that DPID writes stores Z = 1, so normals are flattened on Quest. For unswizzled textures this includes mip 0. For swizzled / Flip Green textures mip 0 is kept but mips 1 and smaller are still affected, so the 3.10.6 fix does not fully cover Android.
Environment
  • VRChat SDK 3.10.6 (3.10.5 compared), Unity 2022.3.22f1, D3D11 editor
  • DPID mipmaps on, conservative mode on
  • Texture Type: Normal map, Mipmap Filtering: Kaiser, PNG source
  • PC: Windows target, DXT5nm normal map encoding (the default). Android: XYZ encoding (the default)
What I think is happening
Notation: below, x, y, z and alpha are the stored [0, 1] channel values; X, Y, Z are the decoded [-1, 1] components.
When
OnPostprocessTexture
runs, Unity has already re-encoded the normal map. Dumping the uncompressed import result shows:
  • PC (DXT5nm target): each pixel is (1, y, y, x). Unity itself discards the PNG alpha at this point.
  • Android (XYZ target): each pixel is (x, y, z, 1).
The layout depends on the target format: judging from the BC5 measurements, a BC5 target appears to keep x in R.
For textures with no swizzle, Flip Green or similar settings,
PerceptualPostProcessor
takes the
fromFile
path: it reloads the raw PNG as (x, y, z, alpha) via
ImageConversion.LoadImage
and regenerates every mip including mip 0 (
inPlace: false
, so
startMip
is 0). The output keeps the raw layout instead of Unity's encoding. (LoadImage only handles PNG and JPG, so TGA / PSD sources fall back to the in-place path.)
  • PC shaders decode X from R × A (
    UnpackNormalmapRGorAG
    ; lilToon does the same). Since 3.10.6 keeps A, the PNG's alpha multiplies the stored x. 3.10.5 forced A to 1 (
    pixel.ba = 1.0f
    ), which hid the leak on this path while destroying X on the in-place path.
  • DXT5nm keeps X in the BC3 alpha block and only Y in the color block. In the raw layout X and Y share the color block, which loses precision.
  • On Android,
    pixel.b = 1.0f
    in the compute shader overwrites Z on both paths, and shaders compiled with
    UNITY_NO_DXT5nm
    read xyz directly.
Swizzled textures already take the in-place path, which is why 3.10.6 fixed that case on PC.
Steps to reproduce (issue 1)
  1. Enable DPID mipmaps in the VRChat SDK settings.
  2. Import an RGBA PNG normal map whose alpha is below 255 (for example 128 everywhere). Texture Type: Normal map, Mipmap Filtering: Kaiser, Compression: None, Read/Write Enabled, no swizzle or Flip Green, Max Size not smaller than the image, Windows target.
  3. Read the mips with
    GetPixels(mip)
    and decode X as 2 × R × A − 1, or view it on a material: X is shifted toward −1.
  4. Set Mipmap Filtering to Box (DPID is skipped) or turn DPID off: X is correct again.
For issue 2, compare a detailed opaque PNG normal map (for example the synthetic one below) at default compression with DPID on and off. For issue 3, switch to Android and check the blue channel of the mips.
Measurements
Test texture: 64 × 64 synthetic normal map with X = 0.7 sin(2πi/16), Y = 0.5 sin(2πj/32 + 1), Z = sqrt(1 − X² − Y²), encoded to PNG. The "alpha 128" variant has alpha 128 everywhere. Values are the mean absolute error of the decoded component against a box-averaged source (not renormalized), in [-1, 1] units (lower is better). "Mips 1-3" is the worst of mips 1 to 3. "No DPID" is Unity's own Kaiser mips, which are sharpened and so also differ from a box average. The 3.10.5 / 3.10.6 / Fix columns were produced with copies of the respective SDK code in a small test project, so please treat them as indicative rather than exact.
Case 3.10.5 3.10.6 Fix No DPID
PC, Flip Green, DXT5, mips 1-3, X 1.000 0.060 0.055 0.097
PC, default, DXT5, mip 0, Y 0.064 0.064 0.007 0.007
PC, default, DXT5, mips 1-3, Y 0.250 0.250 0.055 0.055
PC, alpha 128, uncompressed, mip 0, X 0.002 0.498 0.002 0.002
PC, alpha 128, DXT5, mip 0, X 0.033 0.497 0.009 0.009
PC, alpha 128, BC5, mip 0, X 0.009 0.009 0.009 0.009
Android, default, uncompressed, mip 0, Z 0.216 0.216 0.002 0.002
Android, default, uncompressed, mips 1-3, Z 0.216 0.216 0.020 0.012
Android, Flip Green, uncompressed, mips 1-3, Z 0.216 0.216 0.020 0.012
Android, default, ASTC 6x6, mip 0, Z 0.216 0.216 0.036 0.036
Android, default, ASTC 6x6, mips 1-3, Z 0.216 0.216 0.072 0.074
BC5 did not show the alpha shift in this test (uniform alpha); I have not tested spatially varying alpha.
Proposed fix (for discussion)
Mip normal maps in place, so DPID works on the texture Unity has already encoded, and stop overwriting channels. This is what I am running locally and it seems to behave, but I have only tested the cases listed here.
Editor/VRCSDK/PerceptualPostProcessor.cs
, after the existing
fromFile
checks:
```csharp
if (normalMap)
fromFile = false;
`Editor/VRCSDK/Dependencies/VRChat/Resources/PerceptualMipmapping/PerceptualPostProcessor.compute`, in `Downsampling()`, remove the overwrite:
```hlsl
float4 pixel = _Input[int2(x, y)];
// removed: if (normalMap) pixel.b = 1.0f;
With both changes, the cases I tested came out comparable to Unity's own Kaiser mips: uncompressed, DXT5, BC7 and BC5 on PC, and uncompressed and ASTC on Android, each with default, Flip Green, OneMinusG swizzle and alpha-PNG sources. "Comparable" rather than "identical": in a few cases the fixed DPID mips were slightly further from the box average than Unity's (for example Android uncompressed mips 1-3 Z, 0.020 vs 0.012), which I read as DPID's sharpening rather than an encoding error. With ETC (not my project's default) the fix's Y / Z error at mips 1-3 was somewhat higher than Unity's while X was lower, so I would not call that case a clear win either way.
Things to be aware of:
  • For normal maps larger than Max Size, mip 0 becomes Unity's downscale instead of DPID's, and the smaller mips are generated from that.
  • In the DXT5nm layout B duplicates G, so Y counts twice in the DPID distance. I could not measure a difference on the test texture, but I have not looked further.
  • After applying the code change, existing normal maps keep their current mips until they are reimported. (The SDK's DPID toggle already reimports Kaiser textures, but a code change alone does not.)