texture
import texture from "texture";
let data: string = fs.read_file("rock_albedo.dds") or { return 1; };
var t: texture.Texture = texture.parse(data) or {
io.println("cannot read it: {texture.explain(data)}");
return 1;
};
defer t.free();
io.println("{t.format}, {t.width}x{t.height}, {t.levels} levels"); // BC7 sRGB, 2048x2048, 12 levels
// level 0 as pixels: for a tool, or a GPU without BCn
var s: gfx.Surface = t.to_surface(0, 0, 0) or { return 1; };
defer s.free();texture reads the files games keep their textures in, DDS and KTX2,
and decodes the block-compressed formats inside them. It is pure and
sans-I/O: you hand it the file’s bytes.
- DDS: the legacy header (DXT1 to DXT5, ATI1, ATI2, BC4U/S, BC5U/S, uncompressed RGB, luminance and alpha masks) and the DX10 header (DXGI formats, 1D, 2D and 3D, arrays, cubemaps, cubemap arrays).
- KTX2: levels, the colour space from its data format descriptor, the key/value data, and levels supercompressed with Zstandard or zlib.
- Decoding of BC1, BC2, BC3, BC4, BC5 (unsigned and signed), BC6H
(unsigned and signed) and BC7, every mode, to RGBA8, to f32 or to a
gfx.Surface. The uncompressed formats decode the same way, so one code path handles any texture.
Refused with Unsupported, explain naming what: Basis Universal
(KTX2’s BasisLZ supercompression and UASTC), ASTC, ETC2 and the other
formats outside Format, DDS’s YUV and bump-map formats, and partial
cubemaps.
Parsing
pub fn parse(data: []const u8) -> Texture or TextureError
pub fn explain(data: []const u8) -> string
pub fn Texture.free(*self)parse tells DDS from KTX2 by the first bytes. The Texture borrows
data: keep the bytes alive while you use it. A KTX2 file whose levels
are supercompressed is decompressed by parse into memory the
Texture owns. Call free on every Texture either way; it does
nothing when there is nothing to release.
Everything an image’s place depends on is checked: sizes, the number of mip levels, the data’s length, each KTX2 level’s size. A damaged file gives an error, never a crash.
The error says what kind of failure it was; explain(data) says which.
It parses the bytes again and returns the reason as text, or "" when
they parse:
var t: texture.Texture = texture.parse(data) or {
if (err == texture.TextureError.Unsupported) {
io.println("skipping {name}: {texture.explain(data)}");
// skipping hero.ktx2: ... VkFormat 157, ASTC format
continue;
}
return 1;
};pub enum TextureError : u8 {
UnknownFormat = 1: "not a DDS or KTX2 file";
Corrupt = 2: "malformed texture data";
Unsupported = 3: "a texture format or feature this module does not read";
TooLarge = 4: "a texture over the size limits, or out of memory";
OutTooSmall = 5: "the output cannot hold the image";
}What a texture is
pub struct Texture {
container: Container; // Dds or Ktx2
format: Format;
width: i64; // level 0's
height: i64; // 1 for a 1D texture
depth: i64; // 1 unless 3D
dimension: i64; // 1, 2 or 3
levels: i64; // mip levels in the file
layers: i64; // array layers, 1 when not an array
faces: i64; // 6 for a cubemap, otherwise 1
array: bool; // declared an array, even of one layer
transfer: i64; // 1 linear, 2 sRGB (KTX2's descriptor; DDS: from the format)
primaries: i64; // 1 BT.709 / sRGB
premultiplied: bool; // colour premultiplied by alpha
supercompression: Supercompression; // None, BasisLz, Zstd, Zlib
vk_format: i64; // KTX2's VkFormat, 0 for DDS
dxgi_format: i64; // DDS's DXGI_FORMAT, 0 for KTX2
}vk_format and dxgi_format are there for a renderer that uploads the
data as it is: they are the numbers its graphics API wants.
Format covers what both containers hold in practice:
| Kind | Formats |
|---|---|
| 8 bits a channel | R8, Rg8, Rgb8, Bgr8, Rgba8, Bgra8, Rgbx8, Bgrx8, L8, La8, A8 (and ...Srgb variants of Rgb8, Bgr8, Rgba8, Bgra8, Bgrx8) |
| Packed 16 bits | B5G6R5, B5G5R5A1, B4G4R4A4 |
| Float | R16f, Rg16f, Rgba16f, R32f, Rg32f, Rgb32f, Rgba32f |
| Block-compressed | Bc1, Bc1Rgb, Bc2, Bc3, Bc7 (each with a ...Srgb variant), Bc4, Bc4Snorm, Bc5, Bc5Snorm, Bc6hUfloat, Bc6hSfloat |
pub fn is_compressed(f: Format) -> bool
pub fn is_srgb(f: Format) -> bool
pub fn unit_bytes(f: Format) -> i64 // bytes per pixel, or per 4x4 block
pub fn image_size(f: Format, width: i64, height: i64, depth: i64) -> i64let n: i64 = texture.image_size(texture.Format.Bc1, 18, 10, 1); // 120: 5 x 3 blocks of 8 bytesImages
pub fn Texture.image(*const self, level: i64, layer: i64, face: i64) -> []const u8
pub fn Texture.level_width(*const self, level: i64) -> i64
pub fn Texture.level_height(*const self, level: i64) -> i64
pub fn Texture.level_depth(*const self, level: i64) -> i64image is the bytes of one image: a mip level of an array layer’s
cubemap face. Faces go +X, -X, +Y, -Y, +Z, -Z. Use 0 for the layer and
the face of a texture that has none. A 3D texture’s image holds all of
its depth slices, one after the other. The images of DDS and KTX2 lie
in different orders in their files; image finds them either way.
// a cubemap's mip chain, as a renderer uploads it
for (face in 0..t.faces) {
for (l in 0..t.levels) {
let bytes: []const u8 = t.image(l, 0, face);
upload(face, l, t.level_width(l), t.level_height(l), bytes);
}
}An index out of range traps.
KTX2 key/value data
pub fn Texture.value(*const self, key: []const u8) -> ?[]const u8
pub fn Texture.entries(*const self) -> Entries
pub fn Entries.next(*self) -> ?Entry // Entry { key, value }let o: []const u8 = t.value("KTXorientation") or { "rd\x00" };
for (e in t.entries()) {
io.println("{e.key}: {e.value.len} bytes");
}Values are as stored: a text value keeps its terminating NUL. A DDS
file has none. texture applies none of them (KTXswizzle,
KTXorientation): they are yours to act on.
Decoding
pub fn decode_rgba8(f: Format, src: []const u8, width: i64, height: i64, out: []u8) -> void or TextureError
pub fn decode_rgba32f(f: Format, src: []const u8, width: i64, height: i64, out: []f32) -> void or TextureError
pub fn decode_linear(f: Format, src: []const u8, width: i64, height: i64, out: []f32) -> void or TextureError
pub fn decode_surface(f: Format, src: []const u8, width: i64, height: i64) -> gfx.Surface or TextureError
pub fn Texture.to_surface(*const self, level: i64, layer: i64, face: i64) -> gfx.Surface or TextureErrorsrc is an image of f (a 3D texture’s first slice is decoded); out
gets width * height pixels, rows without padding, red, green, blue
and alpha. to_surface decodes one image of the texture into a new
Argb surface (release it with free()).
let w: i64 = t.level_width(2);
let h: i64 = t.level_height(2);
var px: [65536]u8;
texture.decode_rgba8(t.format, t.image(2, 0, 0), w, h, px[0..w * h * 4]) or { return 1; };What the pixels hold:
- Colour as stored. sRGB textures stay sRGB-encoded, in f32 too.
decode_linearisdecode_rgba32fwith an sRGB format’s red, green and blue converted to linear by the exact sRGB curve (c / 12.92up to 0.04045,((c + 0.055) / 1.055)^2.4above); alpha is never converted, and a format that is not sRGB comes out unchanged. - Missing channels as a GPU samples them:
R8and BC4 give(r, 0, 0, 255),Rg8and BC5(r, g, 0, 255),A8(0, 0, 0, a).L8andLa8are gray. - HDR and signed data (BC6H, the float formats, BC4 and BC5
signed):
decode_rgba32fgives the values exactly, beyond 0..1 and negative where the format allows.decode_rgba8and surfaces clamp them to 0..1 (no tone mapping); signed values map -1..1 to 0..255. - BC1 to BC5 compute the colours between the endpoints in integers, truncating, as Pillow does; a GPU may differ by one.
decode_rgba8, decode_rgba32f and decode_linear fail with Corrupt when src is
shorter than an image and OutTooSmall when out is short. A width or
height below 1 traps.
Speed, natively: a 2048x2048 BC7 level decodes in about 60 ms, BC6H in 80 ms, BC1, BC3 and BC5 in 20 ms.
See also image for PNG and JPEG, zstd, and
gpu (which takes these formats’ blocks as they are, where
Info.bc_textures says the device has them, or the decoded RGBA8 pixels).
Last modified: