And my ENV Packer:
#if UNITY_EDITOR
using System;
using System.IO;
using System.Text;
using System.Collections.Generic;
using UnityEngine;
public static class ENVPacker
{
// Magic bytes used by BabylonJS env format
private static readonly byte[] MagicBytes = new byte[] { 0x86, 0x16, 0x87, 0x96, 0xF6, 0xD6, 0x96, 0x36 };
private static readonly string[] FaceNames = { "PositiveX","NegativeX","PositiveY","NegativeY","PositiveZ","NegativeZ" };
public static void BuildSingleCubemapEnvFromPrefilteredRenderTexture(
RenderTexture cube,
string outEnvPath,
string imageType = "image/webp",
bool rgba32f = false,
string dumpFacesPngDir = null,
bool flipYForDumpOnly = true,
float lodGenerationScale = 0.8f,
float lodGenerationOffset = 0.0f,
bool forceAllMipsToBase = false)
{
if (cube == null) throw new ArgumentNullException(nameof(cube));
if (cube.dimension != UnityEngine.Rendering.TextureDimension.Cube || cube.volumeDepth != 6) throw new Exception("cube.volumeDepth MUST be 6. Allocate with RenderTextureDescriptor dimension=Cube, volumeDepth=6.");
if (!cube.useMipMap) throw new Exception("cube.useMipMap must be true (need full mip chain down to 1x1).");
int baseSize = cube.width;
if (cube.height != baseSize) throw new Exception("Cubemap faces must be square.");
int mipCount = CalcMipCount(baseSize);
if (!string.IsNullOrEmpty(dumpFacesPngDir))
{
DumpCubemapFacesToPng(cube, dumpFacesPngDir, mipCount, rgba32f, flipYForDumpOnly);
}
Directory.CreateDirectory(Path.GetDirectoryName(outEnvPath) ?? ".");
// Collect encoded images in the order expected by Babylon env (mip major, face minor)
var encodedImages = new List<byte[]>();
encodedImages.Capacity = mipCount * 6;
// Buffer to optionally store base-face (mip 0) images for debug duplication
byte[][] baseFaceImages = new byte[6][];
for (int mip = 0; mip < mipCount; mip++)
{
int mipSize = Math.Max(1, baseSize >> mip);
// Create a temporary 2D RT for copying each face+mip
var rtDesc = new RenderTextureDescriptor(mipSize, mipSize, rgba32f ? RenderTextureFormat.ARGBFloat : RenderTextureFormat.ARGBHalf, 0);
rtDesc.sRGB = false;
rtDesc.useMipMap = false;
rtDesc.autoGenerateMips = false;
rtDesc.msaaSamples = 1;
rtDesc.dimension = UnityEngine.Rendering.TextureDimension.Tex2D;
for (int faceIndex = 0; faceIndex < 6; faceIndex++)
{
var tmp = RenderTexture.GetTemporary(rtDesc);
tmp.Create();
try
{
Graphics.CopyTexture(cube, faceIndex, mip, tmp, 0, 0);
// If debug forcing is enabled and this is not mip 0, reuse the stored base image
if (forceAllMipsToBase && mip > 0 && baseFaceImages[faceIndex] != null)
{
encodedImages.Add(baseFaceImages[faceIndex]);
continue;
}
// Read into CPU texture
var texFormat = rgba32f ? TextureFormat.RGBAFloat : TextureFormat.RGBAHalf;
var cpu = new Texture2D(mipSize, mipSize, texFormat, false, true);
var prev = RenderTexture.active;
RenderTexture.active = tmp;
cpu.ReadPixels(new Rect(0, 0, mipSize, mipSize), 0, 0, false);
cpu.Apply(false, false);
RenderTexture.active = prev;
// Encode image using existing export helper which will RGBD-encode HDR when needed.
// Use flipY only for dumping purposes; Unity's ReadPixels maps correctly for our ordering.
var exported = UnityTools.RenderExportTexture(ref cpu, false, false, rgbaFloat: false, encodeRgbd: true);
try
{
byte[] png = exported.EncodeToPNG();
// If user requested webp, convert via existing toolchain (write temp PNG, run cwebp, read back .webp bytes)
if (!string.IsNullOrEmpty(imageType) && imageType.StartsWith("image/webp", StringComparison.OrdinalIgnoreCase))
{
// Enforce lossless WebP (forced).
string tmpPng = UnityTools.GetTempFileName("png");
string tmpWebp = UnityTools.GetTempFileName("webp");
try
{
File.WriteAllBytes(tmpPng, png);
// Create WEBP using existing helper (which uses lossless flags by default)
UnityTools.CreateCompressedTextures(tmpPng, tmpWebp, false, false, 1);
if (!File.Exists(tmpWebp)) throw new Exception($"Failed to generate webp: {tmpWebp}");
byte[] webpBytes = File.ReadAllBytes(tmpWebp);
// Store base face for debug duplication
if (mip == 0) baseFaceImages[faceIndex] = webpBytes;
encodedImages.Add(webpBytes);
}
finally
{
try { File.Delete(tmpPng); } catch { }
try { File.Delete(tmpWebp); } catch { }
}
}
else
{
// Default: PNG bytes
// Store base face for debug duplication
if (mip == 0) baseFaceImages[faceIndex] = png;
encodedImages.Add(png);
}
}
finally
{
UnityEngine.Object.DestroyImmediate(exported);
UnityEngine.Object.DestroyImmediate(cpu);
}
}
finally
{
RenderTexture.ReleaseTemporary(tmp);
}
}
}
// Build JSON manifest
var specularArray = new List<Dictionary<string, int>>(encodedImages.Count);
int position = 0;
foreach (var img in encodedImages)
{
specularArray.Add(new Dictionary<string, int>
{
{ "length", img.Length },
{ "position", position }
});
position += img.Length;
}
// Build a simple manifest string matching EnvironmentTextureInfo v2 used by BabylonJS
// We'll set irradiance to null and include specular.mipmaps array + lodGenerationScale
var sb = new StringBuilder();
sb.Append('{');
sb.Append("\"version\":2,");
sb.AppendFormat("\"width\":{0},", baseSize);
sb.AppendFormat("\"imageType\":\"{0}\",", imageType);
sb.Append("\"irradiance\":null,");
sb.Append("\"specular\":{");
// mipmaps
sb.Append("\"mipmaps\":[");
for (int i = 0; i < specularArray.Count; i++)
{
var d = specularArray[i];
sb.Append('{');
sb.AppendFormat("\"length\":{0},\"position\":{1}", d["length"], d["position"]);
sb.Append('}');
if (i + 1 < specularArray.Count) sb.Append(',');
}
sb.Append("],");
sb.AppendFormat("\"lodGenerationScale\":{0},", lodGenerationScale.ToString(System.Globalization.CultureInfo.InvariantCulture));
sb.AppendFormat("\"lodGenerationOffset\":{0}", lodGenerationOffset.ToString(System.Globalization.CultureInfo.InvariantCulture));
sb.Append("}");
sb.Append('}');
byte[] infoBuffer = Encoding.UTF8.GetBytes(sb.ToString());
// Now write final file: magic bytes + json + null terminator + binary payload
using (var fs = File.Create(outEnvPath))
using (var bw = new BinaryWriter(fs))
{
// Magic
bw.Write(MagicBytes);
// JSON header
bw.Write(infoBuffer);
// Null terminator
bw.Write((byte)0x00);
// Append image payload in the same order
foreach (var img in encodedImages)
{
bw.Write(img);
}
bw.Flush();
}
}
public static void DumpCubemapFacesToPng(RenderTexture cube, string outDir, int mipCount = -1, bool rgba32f = false, bool flipY = true)
{
if (cube == null) throw new ArgumentNullException(nameof(cube));
Directory.CreateDirectory(outDir);
int baseSize = cube.width;
if (mipCount <= 0) mipCount = CalcMipCount(baseSize);
for (int mip = 0; mip < mipCount; mip++)
{
int mipSize = Math.Max(1, baseSize >> mip);
for (int faceIndex = 0; faceIndex < 6; faceIndex++)
{
var rtDesc = new RenderTextureDescriptor(mipSize, mipSize, rgba32f ? RenderTextureFormat.ARGBFloat : RenderTextureFormat.ARGBHalf, 0);
rtDesc.sRGB = false;
rtDesc.dimension = UnityEngine.Rendering.TextureDimension.Tex2D;
rtDesc.useMipMap = false;
rtDesc.autoGenerateMips = false;
var tmp = RenderTexture.GetTemporary(rtDesc);
tmp.Create();
try
{
Graphics.CopyTexture(cube, faceIndex, mip, tmp, 0, 0);
var cpu = new Texture2D(mipSize, mipSize, TextureFormat.RGBA32, false, true);
var prev = RenderTexture.active;
RenderTexture.active = tmp;
cpu.ReadPixels(new Rect(0, 0, mipSize, mipSize), 0, 0, false);
cpu.Apply(false, false);
RenderTexture.active = prev;
if (flipY)
{
cpu = FlipY(cpu);
}
byte[] png = cpu.EncodeToPNG();
string file = Path.Combine(outDir, $"m{mip}_{faceIndex}_{FaceNames[faceIndex]}.png");
File.WriteAllBytes(file, png);
UnityEngine.Object.DestroyImmediate(cpu);
}
finally
{
RenderTexture.ReleaseTemporary(tmp);
}
}
}
}
private static Texture2D FlipY(Texture2D src)
{
var dst = new Texture2D(src.width, src.height, src.format, false, true);
var pixels = src.GetPixels32();
var outPix = new Color32[pixels.Length];
int w = src.width, h = src.height;
for (int y = 0; y < h; y++)
{
int srcRow = y * w;
int dstRow = (h - 1 - y) * w;
Array.Copy(pixels, srcRow, outPix, dstRow, w);
}
dst.SetPixels32(outPix);
dst.Apply(false, false);
UnityEngine.Object.DestroyImmediate(src);
return dst;
}
private static int CalcMipCount(int size)
{
int m = 1;
while (size > 1) { size >>= 1; m++; }
return m;
}
}
#endif
So ExportBabylonIblEnvFromHdrCubemap is my main export function that uses my IBLPrefilterGGX shader to generate a native Unity CubeRenderTexture.
Then ENVPacker. BuildSingleCubemapEnvFromPrefilteredRenderTexture that I made from studting the IBL Texture Tools project (Thank you very much)
And … Bobs your uncle. Native BabylonJS .env filesDynamically generated from the unity scene at export. Including the global environment .env, the Unity gnerated Sperical Harmonics (not the ones generated from environment texture polynomials) and all the reflection probes thru out the scene…
NO MORE EXTERNAL TOOLS LIKE Lys, IBLBaker, CMFT or CMGEN
I Prefilter GGX style directly in unity with that Shader i showed you. Then pack the faces directly in the .env json using that ENVPacker i show you…
And i can control the lodGenerationScale and lodGenerationOffset at baking to set the Roughness used in the the Prefilter Stage…
Thanks again for the Babylon Texture Tools, I used that as a guide for my ENVPacker.
FYI… I had this working with a DDSPacker before… Just needed to finie tune the export process and replace my packer with the ENVPacker.
Work beautifully (EXCEPT THAT MIP MAP ISSUE I AM WORKING ON)