Skip to content

dh.texture

Extension: .dh-tex
Type ID: dh.texture

Texture definitions describe compile-time texture operations. The compiler processes them during dh build; runtimes just load the pre-baked results.

Two modes: channel packing (pack) or single source (source). They’re mutually exclusive.

Textures are consumed by dh.material assets (via their textures channels). A dh.map does not reference textures directly — its material slots reference dh.material assets, which in turn reference textures.

PropertyTypeRequiredDescription
$type"dh.texture"noType identifier
namestringnoDisplay name
descriptionstringnoDescription
packobjectone ofChannel packing map (mutually exclusive with source)
sourcestringone ofSingle source texture path (mutually exclusive with pack)
gradeobjectnoColor grade applied to the produced pixels. Composes with both pack and source.
size[w, h]noTarget dimensions. Omit to keep original size.
filterstringnoFilter mode: "point", "bilinear", "trilinear". Default: platform default.
wrapstringnoWrap mode: "repeat", "clamp", "mirror". Default: platform default.
sRGBboolnoColor space. true (default) = sRGB color, decoded to linear on sample. false = raw linear DATA texture (normal / roughness / metallic / occlusion / height / mask).
rolestringnoDeclared role: "palette" for a palette or lookup texture. Implies no mips, no lossy compression and point filtering. See Palette textures.
mipmapsboolnoWhether the texture gets a mip chain. Default: true, or false for a palette. Wins over role.
compressionstringno"block" (lossy block compression, the default) or "none" (stored exactly as authored; the palette default). Wins over role.

The type is inferred from the file extension, so $type is not needed in source files. The compiler adds it automatically during builds.

Combine channels from multiple source textures into a single output. Classic use case: ORM (Occlusion, Roughness, Metallic) packing.

body-orm.dh-tex
{
"name": "Body ORM",
"pack": {
"r": "textures/occlusion.png@r",
"g": "textures/roughness.png@r",
"b": "textures/metallic.png@r"
}
}

Each key is an output channel (r, g, b, a). Each value is either a texture reference (with optional @channel suffix) or a constant number (0–1). A key that is not one of r, g, b, a is a compile error, as is a constant outside [0, 1]. A texture reference given without an @channel suffix contributes its red channel.

Prefix the channel letter with ! to invert the extracted value (1 − x, byte-wise 255 - x) before it’s packed. This is the porting case that comes up constantly: a Unity _MetallicGlossMap stores metallic in R and smoothness in A, while DigitalHeaven’s metallicRoughness wants roughness in G and metallic in B. The channel move is a plain pack; the smoothness→roughness step needs the invert:

body-metallic-roughness.dh-tex
{
"name": "Body Metallic-Roughness",
"pack": {
"g": "textures/body-metallicGloss.png@!a",
"b": "textures/body-metallicGloss.png@r"
}
}

@!a reads smoothness from alpha and inverts it into roughness; @r reads metallic unchanged. See Porting from Unity.

Invert only applies to texture references — a constant pack value is already whatever number you wrote, so just write 1 - x yourself. A malformed invert (e.g. @!x, an invalid channel letter) is a compile error, same as any other malformed @channel reference.

Defaults for unspecified channels:

  • RGB channels → 0 (black)
  • Alpha channel → 255 (fully opaque)

Pack channels can be set to a constant intensity using a number from 0 to 1:

ValueByteMeaning
00Black / fully transparent
0.5128Mid-gray / half opacity
1255White / fully opaque

This is useful when you need a solid channel without creating a source texture:

nipple-base.dh-tex
{
"name": "Nipple base texture",
"pack": {
"r": 1,
"g": 1,
"b": 1,
"a": "body-extensions/nipples/mask.png@r"
}
}

White RGB with alpha from a mask. No separate white texture file needed.

Process a single texture, whether to resize it or just reference a source:

body-diffuse-1k.dh-tex
{
"name": "Body Diffuse (1K)",
"source": "textures/body-diffuse.png",
"size": [1024, 1024]
}

If only one dimension is given in size, the other is calculated to preserve the aspect ratio.

Sources are decoded by content sniffing, not by extension: PNG, JPEG, BMP, TGA, GIF (first frame) and PSD read directly. WebP, TIFF, QOI and PNM/PBM read too, through ffmpeg: it has to be installed and on PATH, and a build that meets one of those without it fails naming the file and saying so. Convert the image to PNG to avoid the requirement. What the extension does decide is what happens afterwards:

ExtensionBuild-time mip chainStorage in the pallet
.png, .jpg, .jpegYes — the full chain is generated at build and uploaded in one shotA texture a material binds is encoded to one UASTC KTX 2 holding its chain; any other is stored as-is with a .mipchain beside it
.gif, .webpNo — mips are generated at load insteadStored as-is
Anything elseNoDeflate-compressed into the pallet

A dh.texture — pack or source — always compiles to a .png named after the definition, so a material references myTexture.png even though the source file was something else. Prefer PNG (or JPEG for photographic sources) for anything you want mipped at build time; the mip path is the one that stops distant surfaces shimmering.

The chain is built for the channel that binds it

Section titled “The chain is built for the channel that binds it”

The compiler does not filter every image the same way — it reads the material channel each texture is bound to and picks the matching recipe: a normal map is renormalized after averaging, a baseColor on an alphaMode: mask material has its alpha rescaled per level so the cutout keeps its coverage (foliage stops thinning with distance), baseColor and emissive average in linear light, and metallicRoughness / occlusion / height are averaged verbatim as the numbers they are. See Mipmapping for the full table.

Two consequences for authoring:

  • Bind your normal maps and cutouts. An image no material references falls back to the plain data recipe, which for a normal map means unnormalized mips.
  • Only .png/.jpg/.jpeg get this. A .gif or .webp is mipped at load, where only the color space is known — so a normal map in one of those formats will not be renormalized.

A normal map’s alpha is written by the compiler

Section titled “A normal map’s alpha is written by the compiler”

Standing a mip’s averaged normal back up is what keeps the level shading, but it also throws away the one number that says how much the four parent texels disagreed. The compiler keeps it: every level of a normal chain has the length of its own mean normal written into alpha, 255 for a level that averaged nothing and lower the wilder the normals it swallowed. The base level is 1 by definition.

The renderer turns that length into a Toksvig variance and widens the specular lobe by exactly the normals the level no longer holds, composed into the same estimator that already filters the screen-space spread — so a minified normal map stops sparkling instead of going quietly flat and shiny. See Specular antialiasing.

Two things follow. Alpha in a normal map source is not yours — the compiler overwrites it, which costs nothing because the shader only ever reads three channels from a normal map. And a normal map the compiler never mipped (a .gif, a 1×1, or a pallet compiled before this shipped) simply does not get the widening: the renderer will not read a length out of a channel nobody promised was one.

A compiled normal map is BC5 and has no alpha at all: the lengths travel beside its blocks, exactly as computed, and reach the shader through a texture of their own.

A material in one pallet may name a .dh-tex that lives in another, written as a barcode:

{ "textures": { "baseColor": "dev.example.shared:textures/skin-base.dh-tex" } }

The definition is compiled into a .png in the pallet that owns it, and the reference follows: it loads the generated image, exactly as a same-pallet .dh-tex does. A barcode naming the .png itself works too, and so does a .fbx model, which reads as the .glb it compiles to.

The reference is checked when the referencing pallet is built. The dependency must hold the file, or a definition that compiles to it, in its source or in its compiled pallet; one that does not is a build error naming the asset and the path. A pallet the build can find no copy of is left to load time.

A grade block recolors the texture at build time. The source file on disk is never touched — the grade lives in the .dh-tex next to it, so the numbers stay re-tunable and one source can feed several graded variants.

facade019A-alt.dh-tex
{
"name": "Facade 019 A Color (Alt)",
"source": "/textures/facade019AColor.jpg",
"grade": {
"hue": 0.3,
"saturation": 5.0,
"brightness": 0.15,
"contrast": 0.73
}
}
FieldTypeIdentityDescription
huenumber0Hue shift as a fraction of the color wheel, in [0, 1]. Additive, in HSV.
saturationnumber1Saturation scale. 0 is fully desaturated; above 1 oversaturates.
contrastnumber1Contrast scale about a 0.5 pivot. 0 flattens to mid-gray; above 1 expands.
brightnessnumber1Plain multiplier.

A grade that is identity in all four fields (and an omitted grade) does no work at all and produces byte-identical output to the same definition without it.

The four steps always run in this order, and the order matters:

  1. Hue — RGB → HSV, add, wrap, HSV → RGB.
  2. Saturation — lerp(luma, color, saturation), where luma is 0.3·R + 0.59·G + 0.11·B.
  3. Contrast — lerp(0.5, color, contrast).
  4. Brightness — color × brightness.

Nothing is clamped between steps. A saturation of 5 is meant to drive channels well outside [0, 1], and contrast and brightness pull them back; the only clamp is at the final write to 8-bit.

Hue is the one conditional step: exactly 0 or exactly 1 skips the shift entirely rather than wrapping around to the same place, so a grade that only touches the other three fields cannot pick up any HSV round-trip error. Only the additive HSV mode exists — an Oklab hue mode would arrive as a separate field if it is ever wanted.

The engine renders in linear light, so a grade is applied to linear values: the compiler decodes the source sRGB→linear, runs all four steps, and re-encodes linear→sRGB on write. This is what the GPU would see if the same math ran in a shader on a sampled sRGB albedo.

It also means the numbers do not transfer from an image editor working on 8-bit pixels. sRGB 128 is 0.216 in linear, not 0.5, so the 0.5 contrast pivot sits at sRGB 188 — running the same chain on raw bytes looks plausible and lands somewhere else.

grade is a color operation. Setting it on a texture with "sRGB": false is a compile error: grading a normal, roughness, metallic or occlusion map is not meaningful — those values are not colors, and shifting their hue or lifting their contrast just breaks the surface. Either drop the grade or, if the texture really is color, drop the "sRGB": false.

Out-of-range values are errors, not clamped warnings, matching how a pack constant outside [0, 1] is treated: hue outside [0, 1], a negative saturation / contrast / brightness, and any non-finite number are all rejected at build.

grade is independent of the mode — it applies to whatever pack or source produced. It runs after size, on the pixels that actually ship, matching a shader that grades the texel it sampled and keeping a downscale from averaging already-clamped highlights.

decal-warm.dh-tex
{
"name": "Decal (Warm)",
"pack": {
"r": "textures/decal-color.png@r",
"g": "textures/decal-color.png@g",
"b": "textures/decal-color.png@b",
"a": "textures/decal-mask.png@r"
},
"size": [512, 512],
"grade": { "hue": 0.406, "saturation": 0.83, "contrast": 0.78, "brightness": 0.15 }
}

Alpha is carried through a grade untouched.

The @channel suffix extracts a single channel from a source texture. This works anywhere texture paths are used — in pack values, in source, and in material texture references. Prefix the channel letter with ! to invert the extracted value (1 − x) — see Inverting a Channel.

SyntaxResult
textures/packed.pngFull RGBA texture
textures/packed.png@rRed channel as grayscale
textures/packed.png@gGreen channel as grayscale
textures/packed.png@bBlue channel as grayscale
textures/packed.png@aAlpha channel as grayscale
textures/packed.png@!rRed channel as grayscale, inverted (1 − x)

Works with cross-pallet barcodes too:

dev.example.base:textures/packed-orm.png@r

The @ separator was chosen because it can’t appear in file paths and doesn’t conflict with : used for pallet references. The ! invert marker sits directly in front of the channel letter (@!r, not @r!) so it reads as “not red” rather than as part of a compound suffix.

Textures can specify filter mode and wrap mode to control how they’re sampled at runtime. These settings work with both pack and source modes.

Controls how the texture is interpolated when sampled:

ValueAliasesDescription
"point""nearest"Nearest-neighbor filtering. Sharp pixels, no interpolation. Best for pixel art.
"bilinear"—Smooth interpolation between pixels (platform default).
"trilinear"—Bilinear with mipmap blending.

Controls what happens when UV coordinates go outside [0, 1]:

ValueDescription
"repeat"Tiles the texture (platform default).
"clamp"Clamps to edge pixels.
"mirror"Mirrors the texture at boundaries.
sprite.dh-tex
{
"source": "textures/sprite.png",
"filter": "point",
"wrap": "clamp"
}

When omitted, the platform default is used (typically bilinear filtering and repeat wrapping).

What the engine builds from those two words

Section titled “What the engine builds from those two words”

The engine picks a Vulkan sampler per texture, not per material, so two channels of one material can be sampled differently. The mapping matches Unity’s FilterMode exactly:

filterMagnify / minifyBetween mip levelsAnisotropy
"point"nearestnearestoff
"bilinear"linearnearestoff
"trilinear"linearlinearon (capped at 8x, or the device limit)

Point stays nearest across mips too — that is the half people forget, and a point sprite that softened as it receded would not be point-filtered. wrap becomes the address mode directly: repeat → REPEAT, clamp → CLAMP_TO_EDGE, mirror → MIRRORED_REPEAT. The engine’s default when a texture declares neither is trilinear + repeat.

Turning mipmaps off (the video setting) clamps every sampler to the base level and drops anisotropy; it does not change the filter a texture asked for.

core:maps/render-eval carries a texture-filter station — three cubes wearing one binary black-and-white tile under the three filters, each with a strip running away from the camera behind it. Shoot it with dh render --map core:maps/render-eval --camera texFilter: the cube face reads magnification, the strip reads minification and the mip step.

The engine renders in a linear color pipeline: lighting math runs in linear light and the swapchain re-encodes to sRGB on present. A texture’s sRGB flag tells the GPU how to interpret its texels when sampling:

ValueMeaningUse for
true (default)sRGB color — texels are decoded sRGB→linear on every sample.Base color / albedo, and any texture that holds a color a human picked.
falseLinear data — texels are sampled verbatim, with no transfer.DATA maps whose values are not colors: normal, roughness, metallic, occlusion, height, and mask textures.

This mirrors the “sRGB” checkbox in Unity’s texture importer. Leaving it unset is correct for color textures — only turn it off for data maps, otherwise their values get silently gamma-shifted and light wrong.

rock_normal.dh-tex
{
"source": "textures/rock_normal.png",
"sRGB": false
}

Under the hood the compiler stamps a colorSpace token onto the compiled entry only for linear (data) textures; color textures (the default) leave it off, and at runtime a texture with no token is treated as sRGB color. sRGB textures upload as R8G8B8A8Srgb (GPU decodes on sample); linear textures upload as R8G8B8A8Unorm.

Every host reads that one token, whether the entry is a PNG, a JPEG or a KTX 2. A KTX 2’s own transfer function is not consulted.

HostA linear (data) entry becomesStatus
EngineR8G8B8A8Unorm, or the UNORM block format for a KTX 2✅
Unity Mono mods (ULTRAKILL, Schedule I, PEAK, RoR2, Lethal Company)a Texture2D created with linear: true before LoadImage, or before LoadRawTextureData for a KTX 2🟡 built and unit tested, not yet run in a game
Unity IL2CPP mods (BONELAB, MegaBonk)the same, through the (int, int, TextureFormat, bool, bool) and (int, int, TextureFormat, int, bool) constructors, both native in both games🟡 built and unit tested, not yet run in a game
Unity Editor import (VRChat export, Unity projects)sRGBTexture off on the texture importer, and a KTX 2 .asset created linear. An image a material binds to normal is also imported as a Normal map, as the v4 Poiyomi avatars import theirs.🟡 built and unit tested, not yet run in the editor

Before 2026-09-29 every Unity host sampled every texture as sRGB, so normal, roughness, metallic and occlusion maps were all bent there.

Some textures are not pictures. A palette (or lookup texture) is a grid of cells, and each mesh reads one cell by pointing its UVs at that cell’s center. Nothing is supposed to be interpolated, and every texel is a color the artist picked on purpose. Two things that are right for a picture are wrong for it:

  • Mips average cells. Each reduced level averages 2×2 blocks of the level above, so level 1 of a 16×16 palette holds 64 colors that are blends of neighboring cells, and none of them are in the palette. "filter": "point" does not help: point filtering still selects a smaller level once the surface is far enough away or seen at an angle, and then reads a blend exactly. The result is a mesh that changes color with distance.
  • Block compression moves colors. BC7, UASTC and their relatives fit each 4×4 block to a couple of endpoints and interpolate between them. For a photograph that is invisible. For a palette where every texel is a different color it shifts cells toward their neighbors, so the exact color never reaches the shader.

Declaring the role tells every runtime what the texture is:

color-palette.dh-tex
{
"source": "textures/color-palette.png",
"role": "palette"
}
Implied settingValueWhy
mipmapsfalseOne level is uploaded, and its sampler is clamped to that level.
compression"none"The texels are stored and sampled exactly as authored.
filter"point"No interpolation between cells.

wrap and sRGB are not implied. A palette is color, so the sRGB default is already right.

Each behavior the role implies can also be set directly, with or without a role. An explicit field always wins over the role, and an unset one falls back to the role, then to the default:

FieldUnset, no roleUnset, role: "palette"Set
mipmapstruefalseas written
compression"block""none"as written
filterplatform default"point"as written
palette-with-mips.dh-tex
{
"source": "textures/gradient-ramp.png",
"role": "palette",
"mipmaps": true
}

A misspelled role or compression value is a compile error rather than a silent fall back to the default. For a palette, the default is the exact problem the field exists to prevent.

The combination is decided in one place, TextureImportSettings.Resolve() in DigitalHeaven.Core, and every runtime calls it. The compiled pallet keeps the authored role / mipmaps / compression fields on the file entry and each consumer resolves them:

RuntimeWhat a texture resolved to no mips / no compression gets
CompilerNo .mipchain companion. The encode row is uncompressed RGBA8 instead of the channel role’s block format.
EngineUploaded with mipLevels = 1, no runtime chain queued, sampler clamped to level 0 (MaxLod = 0, no anisotropy). A companion left in an older pallet is ignored.
Unity runtime (Mono and IL2CPP)Texture2D created with mipChain: false before the image is loaded. Runtime textures are never compressed.
Unity Editor importmipmapEnabled = false and textureCompression = Uncompressed on the texture importer.

The compiler writes every texture a material binds as one UASTC KTX 2, except normal maps, which are one BC5 KTX 2 (below) (#336, Engine/design-notes/texture-blocks.md), and every host transcodes it at load to the block format its GPU samples: BC7, ASTC 4x4, or RGBA8 for a host with no block upload. Importers and authors keep writing PNG and JPEG; the sources stay lossless, and only the compiled pallet holds the KTX 2. The managed transcoder every host shares is in DigitalHeaven.Core (UastcTranscoder, byte-identical to Basis Universal’s own), and the encoder is Basis Universal’s basisu, run at build the way Blender is (Toolchain/DigitalHeaven.Imaging/native/basisu/, deployed under Toolchain/DigitalHeaven.Compiler/runtimes/<rid>/native/, or named by DH_BASISU).

A texture ships as a KTX 2 when:

  • a material binds it to a channel, or a dh.texture defines it (its sRGB field then says color or data), and
  • its resolved compression is block, which is the default. A palette and anything marked "compression": "none" stays the exact PNG, with its .mipchain when it has mips.

The entry keeps the path the texture was authored under, so every reference, @channel extraction and cross-pallet reference names it unchanged; a reader knows it by its bytes, and the entry’s MIME type is image/ktx2. The whole mip chain rides inside the file, so a KTX 2 texture has no .mipchain beside it. A texture nothing binds or defines (an icon, a cursor) stays as authored. A texture basisu cannot encode ships as authored with a warning; a build with no basisu fails any pallet that has a texture to encode, since falling back would make two machines build two different pallets.

Lossy is the price, and UASTC at level 2 is what it costs: the stage 0 figures against the texels uploaded before (median dB over the MLTN City closure) are color 51.1, cutout color 46.5, normals 42.0 and data 56.6, and about 1 to 2 dB less through the BC7 transcode. A texture whose texels must be exact says "compression": "none".

The roles above that run UASTC (albedo, packed, height, mask) encode at basisu’s level 2 (“balanced”). The workspace config’s textureEncode block changes which effort those roles get: imported for a pallet an importer made (default "fast", level 1), authored for every other pallet (default "balanced"). "best" is level 3. A pallet counts as imported when its manifest metadata carries an importer stamp (importerVersion or source on a map pallet, generator on the base pallet holding the textures an importer generated). Normal maps, probes and the sky are not affected; they are not Balanced UASTC.

Fast is much cheaper in time (one 2048×1024 texture, single thread: level 1 takes 2.3 s, level 2 12.6 s, level 3 25.3 s) for the same output size. The effort is part of the encode cache key, so changing the setting re-encodes only the textures whose effort changed, and a Fast and a Balanced encode of one texture are two entries that stay side by side: switching back reads the old one. There is no per-texture override yet; the effort is the pallet’s.

Every host reads a UASTC KTX 2 texture entry, and the BC5 KTX 2 the compiler makes of a normal map. An entry is recognized by its bytes (the KTX 2 identifier), not its extension. Only raw UASTC LDR 4x4 and raw BC5 are read: a KTX 2 with supercompression or another format fails to load as that texture, with the pallet and path in the message.

  • The entry’s settings decide sampling. Filter, wrap, color space and mipmaps come from the entry, exactly as for a PNG, so a texture that moves from one kind to the other samples the same.
  • The KTX 2 carries its own mips. Its levels are uploaded as they are; nothing is regenerated. A palette (mipmaps: false) uploads its base level alone. A KTX 2 holding only its base level while its settings want mips is unpacked to RGBA so the host can generate them.
  • A normal chain’s alpha is a length only when the file says so: the key/value entry dhMipSemantic names the mip recipe the levels were reduced with, and Normal turns on the Toksvig widening the renderer reads from it. The compiler encodes the base level with a full-length alpha.
  • The size cap applies first. A texture over client.render.maxTextureSize starts at the first level that fits, and the levels above it are never transcoded.
HostReads a KTX 2 asStatus
Engine, Windows and LinuxBC7 when the device samples it, else ASTC 4x4, else RGBA8✅ headless render within 52.8 dB of its PNG twin
Engine, iOS and AndroidASTC 4x4 when the device samples it, else BC7, else RGBA8🟡 built, not yet run on a device
Unity Mono modsBC7, else ASTC 4x4, else RGBA32, through LoadRawTextureData(byte[])🟡 reader tested, not yet run in a game
Unity IL2CPP mods (BONELAB, MegaBonk)the same, through the APIs native in both games: never SetPixelData, the pointer LoadRawTextureData or LoadImage🟡 reader tested, not yet run in a game
Unity Editor importa Texture2D .asset in the build target’s format (BC7 for desktop, ASTC 4x4 for Android and iOS, RGBA32 otherwise), rewritten when the target changes🟡 built, not yet run in the editor
StudioRGBA, its base level✅
GMod / Source exportRGBA, then the exporter’s own DXT encode✅ same VTF as its PNG twin

Unity only takes a block format at whole-block sizes, so a texture whose width or height is not a multiple of 4 loads as RGBA32 there. The engine takes any size. Unity creates a KTX 2 texture in its entry’s color space, as it does a PNG entry, so the two kinds sample the same there too.

A KTX 2 stores its rows top-down, which is what the engine’s Vulkan reads. Unity’s LoadRawTextureData takes them bottom-up, so every Unity host transcodes a KTX 2 flipped (flipVertical on the transcoder), and the stored pallet, the engine and the Source export stay as they are. The flip happens in the compressed form, never as a UV change and never by falling back to uncompressed:

  • Whole blocks reverse their order in the level, and each block’s weights reverse their rows.
  • A block with two or three subsets takes the vertically mirrored partition: BC7 has a mirror for 62 of its 64 two-subset and 59 of its 64 three-subset partitions, and for 30 of the 30 two-subset and 11 of the 11 three-subset partitions UASTC itself uses; ASTC 4x4 has one for 28 of those 30 and 10 of those 11. BC7’s own anchor rule is redone by the BC7 packer, which swaps the endpoints and inverts the weights where the new anchor needs it.
  • A split block with no mirror is decoded, flipped and fitted again in the same mode over the partitions nearest the flipped shape (UastcTranscoder.RefitCandidates, default 6), least-squares endpoints each, keeping the lowest error. On real textures that is under 1 percent of blocks.
  • A level under four rows is one block whose valid rows reverse among themselves. A level over four rows that is not a multiple of four has blocks that straddle the flipped grid, so such a texture loads as RGBA32, which flips at any height (UastcTranscoder.CanFlipAsBlocks).
  • A KTX 2 that loads as RGBA32 (an odd size, or a base level that gets its mips generated) flips its rows in the decode. PNG entries (compression: none pixel art, a normal map with no mips) never needed it: LoadImage and the managed decoder’s UnityImageLevels already hand Unity bottom-first rows.

What a texture is for decides how it is encoded

Section titled “What a texture is for decides how it is encoded”

A texture file carries no statement of its own intent: a path is a naming convention, not a contract. The only place intent is stated is the material channel the image is bound to, so the compiler classifies each output by its binding and looks the encode up in one table. Every row below is the same format, UASTC through basisu at its level 2 by default (encode effort) with no RDO; a role chooses the metric basisu fits against and the recipe the levels are reduced with.

RoleBound channelsMip recipeMetricSampled as
albedobaseColor, emissivecolor (cutout color for an alpha-tested base color)sRGBsRGB
normalnormal, detail normalnormal, renormalized, each level’s length keptnot UASTC: BC5, lengths exact beside itlinear
packedmetallicRoughnessdatalinearlinear
heightheightdatalinearlinear
maskocclusion, detail metallic / roughnessdatalinearlinear
lightmap(baked output)not UASTC (LDR only): BC6H when it landsn/alinear
probe(baked output)not an image: RGBA8, LZ4n/alinear

The role table that predates option B named a native encoder per role (BC7 with endpoint RDO for color, BC5 for normals, BC4 for masks). It was measured and never wired, and one UASTC file replaces all of it: a device transcodes to the format it samples, so the file does not pick the GPU format. Against the best native encoder per role, UASTC gives up about 1.2 dB on color, 5.7 dB on normals (BC5) and 5 dB on data, and in return a pallet holds one file per texture that the Apple GPUs and the desktop ones both read. A dh.texture nothing binds is classified by its sRGB field: sRGB reads as albedo, linear as packed.

An image bound to two channels resolves to the more fidelity-critical of the two roles.

A texture that resolves to "compression": "none" stays the PNG it was.

A texture bound to normal (or a detail block’s normal) compiles to one BC5 KTX 2: X and Y as two BC4 planes, fitted by DigitalHeaven’s own encoder (NormalBlocks in Core, managed and deterministic), with Z rebuilt wherever the map is read. It is a quarter of the size the RGBA8 chain was, and desktop GPUs sample it as it is.

  • The length of every reduced level is kept exact. Specular antialiasing (Toksvig) widens roughness by how far each level’s mean-normal length is from one, which makes it sensitive to a single step of 255: UASTC moved it by 1 to 2 at the low levels (core’s specular-antialiasing deck rendered 42.1 dB from its PNG build), and BC4 by up to 13. So the lengths ride in the same file, uncompressed, under the key/value entry dhNormalLength, and the engine uploads them as an R8 texture half the map’s size, which a sixth of the blocks’ bytes pays for. Sampled at the map’s own coordinates, that half-size texture reads the length of exactly the level the map is read at.
  • X and Y are those of the unit vector. A map that is not unit length keeps its direction. A normal pointing below the surface, which two channels cannot hold, is laid on the horizon.
  • The cost. Video memory falls to 31% of the RGBA8 chain, and the stored bytes to about two thirds, since the PNGs it replaces were already compressed. Directions move by 0.2 to 0.25 degrees on average and 2.3 to 2.4 at the 99th percentile over 69 shipped maps. Distant surfaces render within 60 to 65 dB of the old build, and close-ups within 33 to 37 dB, on the grout edges (Engine/design-notes/texture-blocks.md, “Normal maps as BC5”).
  • What stays RGBA8: a normal map whose settings resolve to no mips (its alpha may be what a material reads), "compression": "none", and an image no material in its own pallet binds, each with its .mipchain as before.
HostReads a BC5 normal map asStatus
Engine, any GPU that samples BC (desktop, M-series iPad through MoltenVK)BC5, plus the R8 length chain✅ headless render, distant frames within 60 dB of the RGBA8 build
Engine, a GPU that samples no BC (A-series iPad, Android), and an ASTC-first deviceRG8, two bytes a texel, plus the R8 length chain🟡 built and unit tested, not yet run on a device
Engine with client.render.textureBlocks offRGBA8 with Z rebuilt and the length in alpha, the old normal chain✅
Unity mods, where BC7 is sampledTextureFormat.BC5, flipped by reversing each block’s index rows; UnpackNormal reads it as it is🟡 unit tested, not yet run in a game
Unity mods elsewhere, Unity Editor import for mobileRGBA32 with Z rebuilt and full alpha, which both of UnpackNormal’s conventions read🟡 unit tested, not yet run
Unity Editor import for desktop (VRChat export)a BC5 Texture2D .asset🟡 built, not yet run in the editor
Studio, GMod / Source exportRGBA, Z rebuilt✅

Raw UASTC is not incompressible the way native BC7 is: over the MLTN City closure the pallet’s LZ4 L00_FAST takes it from 948 to 706 MiB, so every UASTC row asks for the pass, whatever extension the entry wears.

Encoding is expensive (about 3 s of one core for a 512² texture with its chain; a 2048² one is minutes of core time) and perfectly repeatable, so results are cached under the workspace cache directory (texture-cache), and a warm build decodes nothing. The asymmetry drives the design: a miss costs one encode, a wrong hit ships an asset that looks corrupt with nothing in the build log to explain it. So the key covers everything that could change a single output byte:

schemaVersion, source content hash (never a path or mtime), source gamma, alpha mode, source bit depth, target format, color intent, swizzle, encoder id + version + build id, quality preset, RDO mode, RDO λ, RDO window, mip semantic, alpha cutoff, mip count, alignment policy.

  • Nothing needs a human to bump a version. The key is the SHA-256 of the key material text itself, so adding a field to that material changes every key mechanically.
  • The encoder’s identity is derived, not typed. The build id is the hash of the basisu executable, so a rebuilt encoder at the same version number misses every entry the old one made.
  • Encodes run in parallel (DH_IMPORT_THREADS caps them), basisu on one thread each, a window ahead of the pallet’s file loop. Two builds of one texture are the same bytes, on Windows and Linux.

A change to what the compiler makes of the same sources bumps the texture stage (CompilerStages.Texture), which compiles again only the pallets holding images and misses every encode the old texture stage made; a role-table change or a new basisu is one. The cache keeps its own byte budget, textureCacheBudgetBytes (32 GiB by default), so building one pallet does not sweep away another’s encodes.

Plain texture files (.png, .jpg, etc.) can carry import settings through a sidecar file without needing a full .dh-tex definition. Name the sidecar by appending .dh-tex-settings to the texture filename:

  • Directorytextures/
    • sprite.png
    • sprite.png.dh-tex-settings

The sidecar is a JSON file with the same filter, wrap, sRGB, role, mipmaps and compression properties:

sprite.png.dh-tex-settings
{
"filter": "point",
"wrap": "clamp"
}
rock_normal.png.dh-tex-settings
{
"sRGB": false
}
color-palette.png.dh-tex-settings
{
"role": "palette"
}

Sidecar files are consumed during compilation and don’t appear in the compiled pallet. The settings are baked into the pallet’s file metadata.

When to use which:

  • .dh-tex — when you also need channel packing, resize, or other compile-time processing
  • .dh-tex-settings sidecar — when you just need import settings on an existing texture file
body-orm.dh-tex
{
"name": "Body ORM",
"pack": {
"r": "textures/body-ao.png@r",
"g": "textures/body-roughness.png@r",
"b": "textures/body-metallic.png@r"
}
}
body-diffuse-mobile.dh-tex
{
"name": "Body Diffuse (Mobile)",
"source": "textures/body-diffuse-4k.png",
"size": [512, 512]
}
decal.dh-tex
{
"name": "Decal with Transparency",
"pack": {
"r": "textures/decal-color.png@r",
"g": "textures/decal-color.png@g",
"b": "textures/decal-color.png@b",
"a": "textures/decal-mask.png@r"
}
}
pixel-sprite.dh-tex
{
"name": "Pixel Sprite",
"source": "textures/sprite-16x16.png",
"filter": "point",
"wrap": "clamp"
}

Point filtering preserves sharp pixel edges, no blurry interpolation. It keeps its mips, which is what stops a receding sprite from sparkling. If the image is a palette rather than a picture, see Palette textures.