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.materialassets (via theirtextureschannels). Adh.mapdoes not reference textures directly — its material slots referencedh.materialassets, which in turn reference textures.
Properties
Section titled “Properties”| Property | Type | Required | Description |
|---|---|---|---|
$type | "dh.texture" | no | Type identifier |
name | string | no | Display name |
description | string | no | Description |
pack | object | one of | Channel packing map (mutually exclusive with source) |
source | string | one of | Single source texture path (mutually exclusive with pack) |
grade | object | no | Color grade applied to the produced pixels. Composes with both pack and source. |
size | [w, h] | no | Target dimensions. Omit to keep original size. |
filter | string | no | Filter mode: "point", "bilinear", "trilinear". Default: platform default. |
wrap | string | no | Wrap mode: "repeat", "clamp", "mirror". Default: platform default. |
sRGB | bool | no | Color space. true (default) = sRGB color, decoded to linear on sample. false = raw linear DATA texture (normal / roughness / metallic / occlusion / height / mask). |
role | string | no | Declared role: "palette" for a palette or lookup texture. Implies no mips, no lossy compression and point filtering. See Palette textures. |
mipmaps | bool | no | Whether the texture gets a mip chain. Default: true, or false for a palette. Wins over role. |
compression | string | no | "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
$typeis not needed in source files. The compiler adds it automatically during builds.
Channel Packing
Section titled “Channel Packing”Combine channels from multiple source textures into a single output. Classic use case: ORM (Occlusion, Roughness, Metallic) packing.
{ "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.
Inverting a Channel
Section titled “Inverting a 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:
{ "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)
Constant Values
Section titled “Constant Values”Pack channels can be set to a constant intensity using a number from 0 to 1:
| Value | Byte | Meaning |
|---|---|---|
0 | 0 | Black / fully transparent |
0.5 | 128 | Mid-gray / half opacity |
1 | 255 | White / fully opaque |
This is useful when you need a solid channel without creating a source texture:
{ "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.
Single Source
Section titled “Single Source”Process a single texture, whether to resize it or just reference a source:
{ "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.
Source formats and compiled output
Section titled “Source formats and compiled output”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:
| Extension | Build-time mip chain | Storage in the pallet |
|---|---|---|
.png, .jpg, .jpeg | Yes — the full chain is generated at build and uploaded in one shot | A 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, .webp | No — mips are generated at load instead | Stored as-is |
| Anything else | No | Deflate-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/.jpegget this. A.gifor.webpis 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.
Referencing a Texture From Another Pallet
Section titled “Referencing a Texture From Another Pallet”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.
Color Grading
Section titled “Color Grading”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.
{ "name": "Facade 019 A Color (Alt)", "source": "/textures/facade019AColor.jpg", "grade": { "hue": 0.3, "saturation": 5.0, "brightness": 0.15, "contrast": 0.73 }}Fields
Section titled “Fields”| Field | Type | Identity | Description |
|---|---|---|---|
hue | number | 0 | Hue shift as a fraction of the color wheel, in [0, 1]. Additive, in HSV. |
saturation | number | 1 | Saturation scale. 0 is fully desaturated; above 1 oversaturates. |
contrast | number | 1 | Contrast scale about a 0.5 pivot. 0 flattens to mid-gray; above 1 expands. |
brightness | number | 1 | Plain 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:
- Hue — RGB → HSV, add, wrap, HSV → RGB.
- Saturation —
lerp(luma, color, saturation), where luma is0.3·R + 0.59·G + 0.11·B. - Contrast —
lerp(0.5, color, contrast). - 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.
Grading runs in linear light
Section titled “Grading runs in linear light”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.
Color textures only
Section titled “Color textures only”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.
Composing with pack and size
Section titled “Composing with pack and size”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.
{ "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.
@channel Syntax
Section titled “@channel Syntax”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.
| Syntax | Result |
|---|---|
textures/packed.png | Full RGBA texture |
textures/packed.png@r | Red channel as grayscale |
textures/packed.png@g | Green channel as grayscale |
textures/packed.png@b | Blue channel as grayscale |
textures/packed.png@a | Alpha channel as grayscale |
textures/packed.png@!r | Red channel as grayscale, inverted (1 − x) |
Works with cross-pallet barcodes too:
dev.example.base:textures/packed-orm.png@rThe @ 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.
Import Settings
Section titled “Import Settings”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.
Filter Mode
Section titled “Filter Mode”Controls how the texture is interpolated when sampled:
| Value | Aliases | Description |
|---|---|---|
"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. |
Wrap Mode
Section titled “Wrap Mode”Controls what happens when UV coordinates go outside [0, 1]:
| Value | Description |
|---|---|
"repeat" | Tiles the texture (platform default). |
"clamp" | Clamps to edge pixels. |
"mirror" | Mirrors the texture at boundaries. |
{ "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:
filter | Magnify / minify | Between mip levels | Anisotropy |
|---|---|---|---|
"point" | nearest | nearest | off |
"bilinear" | linear | nearest | off |
"trilinear" | linear | linear | on (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.
Color Space (sRGB)
Section titled “Color Space (sRGB)”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:
| Value | Meaning | Use 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. |
false | Linear 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.
{ "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.
| Host | A linear (data) entry becomes | Status |
|---|---|---|
| Engine | R8G8B8A8Unorm, 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.
Palette textures (role)
Section titled “Palette textures (role)”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:
{ "source": "textures/color-palette.png", "role": "palette"}| Implied setting | Value | Why |
|---|---|---|
mipmaps | false | One 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.
Explicit overrides (mipmaps, compression)
Section titled “Explicit overrides (mipmaps, compression)”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:
| Field | Unset, no role | Unset, role: "palette" | Set |
|---|---|---|---|
mipmaps | true | false | as written |
compression | "block" | "none" | as written |
filter | platform default | "point" | as written |
{ "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.
Where this is applied
Section titled “Where this is applied”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:
| Runtime | What a texture resolved to no mips / no compression gets |
|---|---|
| Compiler | No .mipchain companion. The encode row is uncompressed RGBA8 instead of the channel role’s block format. |
| Engine | Uploaded 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 import | mipmapEnabled = false and textureCompression = Uncompressed on the texture importer. |
Block Compression
Section titled “Block Compression”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.texturedefines it (itssRGBfield then says color or data), and - its resolved
compressionisblock, which is the default. A palette and anything marked"compression": "none"stays the exact PNG, with its.mipchainwhen 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".
Encode effort
Section titled “Encode effort”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.
Reading a KTX 2 entry
Section titled “Reading a KTX 2 entry”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
dhMipSemanticnames the mip recipe the levels were reduced with, andNormalturns 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.maxTextureSizestarts at the first level that fits, and the levels above it are never transcoded.
| Host | Reads a KTX 2 as | Status |
|---|---|---|
| Engine, Windows and Linux | BC7 when the device samples it, else ASTC 4x4, else RGBA8 | ✅ headless render within 52.8 dB of its PNG twin |
| Engine, iOS and Android | ASTC 4x4 when the device samples it, else BC7, else RGBA8 | 🟡 built, not yet run on a device |
| Unity Mono mods | BC7, 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 import | a 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 |
| Studio | RGBA, its base level | ✅ |
| GMod / Source export | RGBA, 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: nonepixel art, a normal map with no mips) never needed it:LoadImageand the managed decoder’sUnityImageLevelsalready 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.
| Role | Bound channels | Mip recipe | Metric | Sampled as |
|---|---|---|---|---|
albedo | baseColor, emissive | color (cutout color for an alpha-tested base color) | sRGB | sRGB |
normal | normal, detail normal | normal, renormalized, each level’s length kept | not UASTC: BC5, lengths exact beside it | linear |
packed | metallicRoughness | data | linear | linear |
height | height | data | linear | linear |
mask | occlusion, detail metallic / roughness | data | linear | linear |
lightmap | (baked output) | not UASTC (LDR only): BC6H when it lands | n/a | linear |
probe | (baked output) | not an image: RGBA8, LZ4 | n/a | linear |
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.
Normal maps are BC5
Section titled “Normal maps are BC5”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.mipchainas before.
| Host | Reads a BC5 normal map as | Status |
|---|---|---|
| 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 device | RG8, two bytes a texel, plus the R8 length chain | 🟡 built and unit tested, not yet run on a device |
Engine with client.render.textureBlocks off | RGBA8 with Z rebuilt and the length in alpha, the old normal chain | ✅ |
| Unity mods, where BC7 is sampled | TextureFormat.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 mobile | RGBA32 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 export | RGBA, Z rebuilt | ✅ |
The pallet’s LZ4 pass
Section titled “The pallet’s LZ4 pass”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.
The encode cache
Section titled “The encode cache”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
basisuexecutable, so a rebuilt encoder at the same version number misses every entry the old one made. - Encodes run in parallel (
DH_IMPORT_THREADScaps them),basisuon 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.
Sidecar Files
Section titled “Sidecar Files”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:
{ "filter": "point", "wrap": "clamp"}{ "sRGB": false}{ "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-settingssidecar — when you just need import settings on an existing texture file
Examples
Section titled “Examples”ORM Pack
Section titled “ORM Pack”{ "name": "Body ORM", "pack": { "r": "textures/body-ao.png@r", "g": "textures/body-roughness.png@r", "b": "textures/body-metallic.png@r" }}Downscale for Performance
Section titled “Downscale for Performance”{ "name": "Body Diffuse (Mobile)", "source": "textures/body-diffuse-4k.png", "size": [512, 512]}RGBA with Alpha
Section titled “RGBA with Alpha”{ "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 Art with Point Filtering
Section titled “Pixel Art with Point Filtering”{ "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.