diff --git a/.gitattributes b/.gitattributes
index f7bd4d06..80c19213 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -126,6 +126,7 @@
*.dds filter=lfs diff=lfs merge=lfs -text
*.ktx filter=lfs diff=lfs merge=lfs -text
*.ktx2 filter=lfs diff=lfs merge=lfs -text
+*.astc filter=lfs diff=lfs merge=lfs -text
*.pam filter=lfs diff=lfs merge=lfs -text
*.pbm filter=lfs diff=lfs merge=lfs -text
*.pgm filter=lfs diff=lfs merge=lfs -text
diff --git a/ImageSharp.Textures.sln b/ImageSharp.Textures.sln
index 636514f8..7b07d55a 100644
--- a/ImageSharp.Textures.sln
+++ b/ImageSharp.Textures.sln
@@ -49,35 +49,72 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{
.github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageSharp.Textures.Astc.Reference.Tests", "tests\ImageSharp.Textures.Astc.Reference.Tests\ImageSharp.Textures.Astc.Reference.Tests.csproj", "{E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}"
+EndProject
Global
- GlobalSection(SharedMSBuildProjectFiles) = preSolution
- shared-infrastructure\src\SharedInfrastructure\SharedInfrastructure.projitems*{1588f6c4-2186-4a35-9693-e9f296791393}*SharedItemsImports = 5
- tests\Images\Images.projitems*{17fcbd4d-d232-45e8-876f-dfbc2fad52cf}*SharedItemsImports = 5
- tests\Images\Images.projitems*{18be79b6-6b95-4ed7-a963-ad75f6cb9f3c}*SharedItemsImports = 5
- tests\Images\Images.projitems*{68a8cc40-6aed-4e96-b524-31b1158fdeea}*SharedItemsImports = 13
- tests\Images\Images.projitems*{b159ffd1-e646-42d0-892c-4abf69103712}*SharedItemsImports = 5
- EndGlobalSection
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1588F6C4-2186-4A35-9693-E9F296791393}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1588F6C4-2186-4A35-9693-E9F296791393}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Debug|x64.Build.0 = Debug|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Debug|x86.Build.0 = Debug|Any CPU
{1588F6C4-2186-4A35-9693-E9F296791393}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1588F6C4-2186-4A35-9693-E9F296791393}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Release|x64.ActiveCfg = Release|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Release|x64.Build.0 = Release|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Release|x86.ActiveCfg = Release|Any CPU
+ {1588F6C4-2186-4A35-9693-E9F296791393}.Release|x86.Build.0 = Release|Any CPU
{B159FFD1-E646-42D0-892C-4ABF69103712}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B159FFD1-E646-42D0-892C-4ABF69103712}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Debug|x64.Build.0 = Debug|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Debug|x86.Build.0 = Debug|Any CPU
{B159FFD1-E646-42D0-892C-4ABF69103712}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B159FFD1-E646-42D0-892C-4ABF69103712}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Release|x64.ActiveCfg = Release|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Release|x64.Build.0 = Release|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Release|x86.ActiveCfg = Release|Any CPU
+ {B159FFD1-E646-42D0-892C-4ABF69103712}.Release|x86.Build.0 = Release|Any CPU
{18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Debug|x64.Build.0 = Debug|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Debug|x86.Build.0 = Debug|Any CPU
{18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Release|Any CPU.Build.0 = Release|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Release|x64.ActiveCfg = Release|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Release|x64.Build.0 = Release|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Release|x86.ActiveCfg = Release|Any CPU
+ {18BE79B6-6B95-4ED7-A963-AD75F6CB9F3C}.Release|x86.Build.0 = Release|Any CPU
{17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Debug|x64.Build.0 = Debug|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Debug|x86.Build.0 = Debug|Any CPU
{17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Release|x64.ActiveCfg = Release|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Release|x64.Build.0 = Release|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Release|x86.ActiveCfg = Release|Any CPU
+ {17FCBD4D-D232-45E8-876F-DFBC2FAD52CF}.Release|x86.Build.0 = Release|Any CPU
+{E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}.Release|x64.ActiveCfg = Release|Any CPU
+ {E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720}.Release|x86.ActiveCfg = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -90,8 +127,17 @@ Global
{17FCBD4D-D232-45E8-876F-DFBC2FAD52CF} = {6DF92068-B792-4038-8E3F-5FDF2E026BE7}
{E6224AB7-6987-4BA1-B2A6-ECFB7DA281DE} = {9F1F0B0F-704F-4B71-89EF-EE36042A27C9}
{9F19EBB4-32DB-4AFE-A5E4-722EDFAAE04B} = {E6224AB7-6987-4BA1-B2A6-ECFB7DA281DE}
+ {E15C2E1A-FCD6-42B1-82D2-2C72CC4DC720} = {6DF92068-B792-4038-8E3F-5FDF2E026BE7}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {F1762A0D-74C4-454A-BCB7-C010BB067E58}
EndGlobalSection
+ GlobalSection(SharedMSBuildProjectFiles) = preSolution
+ shared-infrastructure\src\SharedInfrastructure\SharedInfrastructure.projitems*{1588f6c4-2186-4a35-9693-e9f296791393}*SharedItemsImports = 5
+ tests\Images\Images.projitems*{17fcbd4d-d232-45e8-876f-dfbc2fad52cf}*SharedItemsImports = 5
+ tests\Images\Images.projitems*{18be79b6-6b95-4ed7-a963-ad75f6cb9f3c}*SharedItemsImports = 5
+ tests\Images\Images.projitems*{68a8cc40-6aed-4e96-b524-31b1158fdeea}*SharedItemsImports = 13
+ tests\Images\Images.projitems*{b159ffd1-e646-42d0-892c-4abf69103712}*SharedItemsImports = 5
+ tests\Images\Images.projitems*{e15c2e1a-fcd6-42b1-82d2-2c72cc4dc720}*SharedItemsImports = 5
+ EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 5be8e659..6fb3d82e 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ SixLabors.ImageSharp.Textures
[](https://github.com/SixLabors/ImageSharp.Textures/actions)
[](https://codecov.io/gh/SixLabors/ImageSharp)
-[](https://opensource.org/licenses/Apache-2.0)
+[](https://github.com/SixLabors/ImageSharp/blob/main/LICENSE)
[](https://twitter.com/intent/tweet?hashtags=imagesharp,dotnet,oss&text=ImageSharp.+A+new+cross-platform+2D+graphics+API+in+C%23&url=https%3a%2f%2fgithub.com%2fSixLabors%2fImageSharp&via=sixlabors)
@@ -33,6 +33,7 @@ with the following compressions:
- BC5
- BC6H
- BC7
+- ASTC
Encoding textures is **not** yet supported. PR are of course very welcome.
diff --git a/shared-infrastructure b/shared-infrastructure
index 57699ffb..4a5a9fe7 160000
--- a/shared-infrastructure
+++ b/shared-infrastructure
@@ -1 +1 @@
-Subproject commit 57699ffb797bc2389c5d6cbb3b1800f2eb5fb947
+Subproject commit 4a5a9fe756e75c92ef9042b0ea4d94bc35e6ace9
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 2813cc4b..2e8451ba 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -11,7 +11,7 @@
-->
-
+
@@ -22,9 +22,10 @@
-
+
+
diff --git a/src/ImageSharp.Textures/Common/Helpers/FloatHelper.cs b/src/ImageSharp.Textures/Common/Helpers/FloatHelper.cs
index 98b8de50..17db12a1 100644
--- a/src/ImageSharp.Textures/Common/Helpers/FloatHelper.cs
+++ b/src/ImageSharp.Textures/Common/Helpers/FloatHelper.cs
@@ -1,6 +1,7 @@
// Copyright (c) Six Labors.
// Licensed under the Six Labors Split License.
+using System;
using System.Runtime.CompilerServices;
namespace SixLabors.ImageSharp.Textures.Common.Helpers
@@ -14,32 +15,31 @@ internal static class FloatHelper
public static uint PackFloatToFloat32(float value) => Unsafe.As(ref value);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static float UnpackFloat16ToFloat(uint value)
- {
- uint result =
- ((value >> 15) << 31) |
- ((((value >> 10) & 0x1f) - 15 + 127) << 23) |
- ((value & 0x3ff) << 13);
- return Unsafe.As(ref result);
- }
+ public static float UnpackFloat16ToFloat(uint value) => (float)BitConverter.UInt16BitsToHalf((ushort)value);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static uint PackFloatToFloat16(float value)
- {
- uint temp = Unsafe.As(ref value);
- return
- ((temp >> 31) << 15) |
- ((((temp >> 23) & 0xff) - 127 + 15) << 10) |
- ((temp & 0x7fffff) >> 13);
- }
+ public static uint PackFloatToFloat16(float value) => BitConverter.HalfToUInt16Bits((Half)value);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static float UnpackFloat10ToFloat(uint value)
+ public static float UnpackFloat10ToFloat(uint value, uint bias = 10)
{
- uint result =
- ((((value >> 5) & 0x1f) - 10 + 127) << 23) |
- ((value & 0x1f) << 18);
- return Unsafe.As(ref result);
+ uint e = (value >> 5) & 0x1Fu;
+ uint m = value & 0x1Fu;
+
+ return e switch
+ {
+ // Zero
+ 0 when m == 0 => 0f,
+
+ // Denormalized
+ 0 => m * BitConverter.UInt32BitsToSingle((128u - bias - 5u) << 23),
+
+ // Inf/NaN
+ 31 => BitConverter.UInt32BitsToSingle((0xFFu << 23) | (m << 18)),
+
+ // Normalized
+ _ => BitConverter.UInt32BitsToSingle(((e + 127u - bias) << 23) | (m << 18)),
+ };
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@@ -52,12 +52,29 @@ public static uint PackFloatToFloat10(float value)
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
- public static float UnpackFloat11ToFloat(uint value)
+ public static float UnpackFloat11ToFloat(uint value, uint bias = 11)
{
- uint result =
- ((((value >> 6) & 0x1f) - 11 + 127) << 23) |
- ((value & 0x3f) << 17);
- return Unsafe.As(ref result);
+ uint e = (value >> 6) & 0x1Fu;
+ uint m = value & 0x3Fu;
+
+ if (e == 0)
+ {
+ if (m == 0)
+ {
+ return 0f;
+ }
+
+ // Denormalized: m * 2^(1 - bias - 6)
+ return m * BitConverter.UInt32BitsToSingle((128u - bias - 6u) << 23);
+ }
+
+ if (e == 31)
+ {
+ uint ieee = (0xFFu << 23) | (m << 17);
+ return BitConverter.UInt32BitsToSingle(ieee);
+ }
+
+ return BitConverter.UInt32BitsToSingle(((e + 127u - bias) << 23) | (m << 17));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
diff --git a/src/ImageSharp.Textures/Compression/Astc/AstcDecoder.cs b/src/ImageSharp.Textures/Compression/Astc/AstcDecoder.cs
new file mode 100644
index 00000000..991d8d20
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/AstcDecoder.cs
@@ -0,0 +1,447 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers;
+using System.Buffers.Binary;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BlockDecoder;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+using SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc;
+
+///
+/// Provides methods to decode ASTC-compressed texture data into uncompressed pixel formats.
+///
+public static class AstcDecoder
+{
+ private static readonly ArrayPool ArrayPool = ArrayPool.Shared;
+ private const int BytesPerPixelUnorm8 = 4;
+
+ ///
+ /// Decompresses ASTC-compressed data to uncompressed RGBA8 format (4 bytes per pixel).
+ ///
+ /// The ASTC-compressed texture data
+ /// Image width in pixels
+ /// Image height in pixels
+ /// The ASTC block footprint (e.g., 4x4, 5x5)
+ /// Array of bytes in RGBA8 format (width * height * 4 bytes total)
+ /// If decompression fails for any block
+ public static Span DecompressImage(ReadOnlySpan astcData, int width, int height, Footprint footprint)
+ {
+ byte[] imageBuffer = new byte[width * height * BytesPerPixelUnorm8];
+
+ return DecompressImage(astcData, width, height, footprint, imageBuffer)
+ ? imageBuffer
+ : [];
+ }
+
+ ///
+ /// Decompresses ASTC-compressed data to uncompressed RGBA8 format into a caller-provided buffer.
+ ///
+ /// The ASTC-compressed texture data
+ /// Image width in pixels
+ /// Image height in pixels
+ /// The ASTC block footprint (e.g., 4x4, 5x5)
+ /// Output buffer. Must be at least width * height * 4 bytes.
+ /// True if decompression succeeded, false if input was invalid.
+ /// If decompression fails for any block
+ public static bool DecompressImage(ReadOnlySpan astcData, int width, int height, Footprint footprint, Span imageBuffer)
+ {
+ if (!TryGetBlockLayout(astcData, width, height, footprint, out int blocksWide, out int blocksHigh))
+ {
+ return false;
+ }
+
+ byte[] decodedBlock = [];
+
+ try
+ {
+ // Create a buffer once for fallback blocks; fast path writes directly to image
+ decodedBlock = ArrayPool.Rent(footprint.Width * footprint.Height * BytesPerPixelUnorm8);
+ Span decodedPixels = decodedBlock.AsSpan();
+ int blockIndex = 0;
+ int footprintWidth = footprint.Width;
+ int footprintHeight = footprint.Height;
+
+ for (int blockY = 0; blockY < blocksHigh; blockY++)
+ {
+ for (int blockX = 0; blockX < blocksWide; blockX++)
+ {
+ int blockDataOffset = blockIndex++ * PhysicalBlock.SizeInBytes;
+ if (blockDataOffset + PhysicalBlock.SizeInBytes > astcData.Length)
+ {
+ continue;
+ }
+
+ ulong low = BinaryPrimitives.ReadUInt64LittleEndian(astcData[blockDataOffset..]);
+ ulong high = BinaryPrimitives.ReadUInt64LittleEndian(astcData[(blockDataOffset + 8)..]);
+ UInt128 blockBits = new(high, low);
+
+ int dstBaseX = blockX * footprintWidth;
+ int dstBaseY = blockY * footprintHeight;
+ int copyWidth = Math.Min(footprintWidth, width - dstBaseX);
+ int copyHeight = Math.Min(footprintHeight, height - dstBaseY);
+
+ BlockInfo info = BlockInfo.Decode(blockBits);
+ if (!info.IsValid)
+ {
+ continue;
+ }
+
+ // Fast path: fuse decode directly into image buffer for interior full blocks
+ if (!info.IsVoidExtent && info.PartitionCount == 1 && !info.IsDualPlane
+ && !info.EndpointMode0.IsHdr()
+ && copyWidth == footprintWidth && copyHeight == footprintHeight)
+ {
+ FusedLdrBlockDecoder.DecompressBlockFusedLdrToImage(
+ blockBits,
+ in info,
+ footprint,
+ dstBaseX,
+ dstBaseY,
+ width,
+ imageBuffer);
+ continue;
+ }
+
+ // Fallback: decode to temp buffer, then copy
+ if (!info.IsVoidExtent && info.PartitionCount == 1 && !info.IsDualPlane
+ && !info.EndpointMode0.IsHdr())
+ {
+ FusedLdrBlockDecoder.DecompressBlockFusedLdr(blockBits, in info, footprint, decodedPixels);
+ }
+ else
+ {
+ LogicalBlock? logicalBlock = LogicalBlock.UnpackLogicalBlock(footprint, blockBits, in info);
+ if (logicalBlock is null)
+ {
+ continue;
+ }
+
+ logicalBlock.WriteAllPixelsLdr(footprint, decodedPixels);
+ }
+
+ int copyBytes = copyWidth * BytesPerPixelUnorm8;
+ for (int pixelY = 0; pixelY < copyHeight; pixelY++)
+ {
+ int srcOffset = pixelY * footprintWidth * BytesPerPixelUnorm8;
+ int dstOffset = (((dstBaseY + pixelY) * width) + dstBaseX) * BytesPerPixelUnorm8;
+ decodedPixels.Slice(srcOffset, copyBytes)
+ .CopyTo(imageBuffer.Slice(dstOffset, copyBytes));
+ }
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Return(decodedBlock);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Decompress a single ASTC block to RGBA8 pixel data
+ ///
+ /// The data to decode
+ /// The type of ASTC block footprint e.g. 4x4, 5x5, etc.
+ /// The decoded block of pixels as RGBA values
+ public static Span DecompressBlock(ReadOnlySpan blockData, Footprint footprint)
+ {
+ byte[] decodedPixels = [];
+ try
+ {
+ decodedPixels = ArrayPool.Rent(footprint.Width * footprint.Height * BytesPerPixelUnorm8);
+ Span decodedPixelBuffer = decodedPixels.AsSpan();
+
+ DecompressBlock(blockData, footprint, decodedPixelBuffer);
+ }
+ finally
+ {
+ ArrayPool.Return(decodedPixels);
+ }
+
+ return decodedPixels;
+ }
+
+ ///
+ /// Decompresses a single ASTC block to RGBA8 pixel data
+ ///
+ /// The data to decode
+ /// The type of ASTC block footprint e.g. 4x4, 5x5, etc.
+ /// The buffer to write the decoded pixels into
+ public static void DecompressBlock(ReadOnlySpan blockData, Footprint footprint, Span buffer)
+ {
+ // Read the 16 bytes that make up the ASTC block as a 128-bit value
+ ulong low = BinaryPrimitives.ReadUInt64LittleEndian(blockData);
+ ulong high = BinaryPrimitives.ReadUInt64LittleEndian(blockData[8..]);
+ UInt128 blockBits = new(high, low);
+
+ BlockInfo info = BlockInfo.Decode(blockBits);
+ if (!info.IsValid)
+ {
+ return;
+ }
+
+ // Fully fused fast path for single-partition, non-dual-plane, LDR blocks
+ if (!info.IsVoidExtent && info.PartitionCount == 1 && !info.IsDualPlane
+ && !info.EndpointMode0.IsHdr())
+ {
+ FusedLdrBlockDecoder.DecompressBlockFusedLdr(blockBits, in info, footprint, buffer);
+ return;
+ }
+
+ // Fallback for void extent, multi-partition, dual plane, HDR
+ LogicalBlock? logicalBlock = LogicalBlock.UnpackLogicalBlock(footprint, blockBits, in info);
+ if (logicalBlock is null)
+ {
+ return;
+ }
+
+ logicalBlock.WriteAllPixelsLdr(footprint, buffer);
+ }
+
+ ///
+ /// Decompresses ASTC-compressed data to RGBA values.
+ ///
+ /// The ASTC-compressed texture data
+ /// Image width in pixels
+ /// Image height in pixels
+ /// The ASTC block footprint (e.g., 4x4, 5x5)
+ ///
+ /// Values in RGBA order. For HDR content, values may exceed 1.0.
+ ///
+ public static Span DecompressHdrImage(ReadOnlySpan astcData, int width, int height, Footprint footprint)
+ {
+ const int channelsPerPixel = 4;
+ float[] imageBuffer = new float[width * height * channelsPerPixel];
+ if (!DecompressHdrImage(astcData, width, height, footprint, imageBuffer))
+ {
+ return [];
+ }
+
+ return imageBuffer;
+ }
+
+ ///
+ /// Decompresses ASTC-compressed data to RGBA float values into a caller-provided buffer.
+ ///
+ /// The ASTC-compressed texture data
+ /// Image width in pixels
+ /// Image height in pixels
+ /// The ASTC block footprint (e.g., 4x4, 5x5)
+ /// Output buffer. Must be at least width * height * 4 floats.
+ /// True if decompression succeeded, false if input was invalid.
+ /// If decompression fails for any block
+ public static bool DecompressHdrImage(ReadOnlySpan astcData, int width, int height, Footprint footprint, Span imageBuffer)
+ {
+ if (!TryGetBlockLayout(astcData, width, height, footprint, out int blocksWide, out int blocksHigh))
+ {
+ return false;
+ }
+
+ const int channelsPerPixel = 4;
+ float[] decodedBlock = [];
+
+ try
+ {
+ // Create a buffer once for fallback blocks; fast path writes directly to image
+ decodedBlock = ArrayPool.Shared.Rent(footprint.Width * footprint.Height * channelsPerPixel);
+ Span decodedPixels = decodedBlock.AsSpan();
+ int blockIndex = 0;
+ int footprintWidth = footprint.Width;
+ int footprintHeight = footprint.Height;
+
+ for (int blockY = 0; blockY < blocksHigh; blockY++)
+ {
+ for (int blockX = 0; blockX < blocksWide; blockX++)
+ {
+ int blockDataOffset = blockIndex++ * PhysicalBlock.SizeInBytes;
+ if (blockDataOffset + PhysicalBlock.SizeInBytes > astcData.Length)
+ {
+ continue;
+ }
+
+ ulong low = BinaryPrimitives.ReadUInt64LittleEndian(astcData[blockDataOffset..]);
+ ulong high = BinaryPrimitives.ReadUInt64LittleEndian(astcData[(blockDataOffset + 8)..]);
+ UInt128 blockBits = new(high, low);
+
+ int dstBaseX = blockX * footprintWidth;
+ int dstBaseY = blockY * footprintHeight;
+ int copyWidth = Math.Min(footprintWidth, width - dstBaseX);
+ int copyHeight = Math.Min(footprintHeight, height - dstBaseY);
+
+ BlockInfo info = BlockInfo.Decode(blockBits);
+ if (!info.IsValid)
+ {
+ continue;
+ }
+
+ // Fast path: fuse decode directly into image buffer for interior full blocks
+ if (!info.IsVoidExtent && info.PartitionCount == 1 && !info.IsDualPlane
+ && copyWidth == footprintWidth && copyHeight == footprintHeight)
+ {
+ FusedHdrBlockDecoder.DecompressBlockFusedHdrToImage(
+ blockBits,
+ in info,
+ footprint,
+ dstBaseX,
+ dstBaseY,
+ width,
+ imageBuffer);
+ continue;
+ }
+
+ // Fused decode to temp buffer for single-partition non-dual-plane
+ if (!info.IsVoidExtent && info.PartitionCount == 1 && !info.IsDualPlane)
+ {
+ FusedHdrBlockDecoder.DecompressBlockFusedHdr(blockBits, in info, footprint, decodedPixels);
+ }
+ else
+ {
+ // Fallback: LogicalBlock path for void extent, multi-partition, dual plane
+ LogicalBlock? logicalBlock = LogicalBlock.UnpackLogicalBlock(footprint, blockBits, in info);
+ if (logicalBlock is null)
+ {
+ continue;
+ }
+
+ for (int row = 0; row < footprintHeight; row++)
+ {
+ for (int column = 0; column < footprintWidth; ++column)
+ {
+ int pixelOffset = (footprintWidth * row * channelsPerPixel) + (column * channelsPerPixel);
+ logicalBlock.WriteHdrPixel(column, row, decodedPixels.Slice(pixelOffset, channelsPerPixel));
+ }
+ }
+ }
+
+ int copyFloats = copyWidth * channelsPerPixel;
+ for (int pixelY = 0; pixelY < copyHeight; pixelY++)
+ {
+ int srcOffset = pixelY * footprintWidth * channelsPerPixel;
+ int dstOffset = (((dstBaseY + pixelY) * width) + dstBaseX) * channelsPerPixel;
+ decodedPixels.Slice(srcOffset, copyFloats)
+ .CopyTo(imageBuffer.Slice(dstOffset, copyFloats));
+ }
+ }
+ }
+ }
+ finally
+ {
+ ArrayPool.Shared.Return(decodedBlock);
+ }
+
+ return true;
+ }
+
+ ///
+ /// Decompresses ASTC-compressed data to RGBA values.
+ ///
+ /// The ASTC-compressed texture data
+ /// Image width in pixels
+ /// Image height in pixels
+ /// The ASTC block footprint type
+ ///
+ /// Values in RGBA order. For HDR content, values may exceed 1.0.
+ ///
+ public static Span DecompressHdrImage(ReadOnlySpan astcData, int width, int height, FootprintType footprint)
+ {
+ Footprint footPrint = Footprint.FromFootprintType(footprint);
+ return DecompressHdrImage(astcData, width, height, footPrint);
+ }
+
+ ///
+ /// Decompresses a single ASTC block to float RGBA values.
+ ///
+ /// The 16-byte ASTC block to decode
+ /// The ASTC block footprint
+ /// The buffer to write decoded values into (must be at least footprint.Width * footprint.Height * 4 elements)
+ public static void DecompressHdrBlock(ReadOnlySpan blockData, Footprint footprint, Span buffer)
+ {
+ // Read the 16 bytes that make up the ASTC block as a 128-bit value
+ ulong low = BinaryPrimitives.ReadUInt64LittleEndian(blockData);
+ ulong high = BinaryPrimitives.ReadUInt64LittleEndian(blockData[8..]);
+ UInt128 blockBits = new(high, low);
+
+ BlockInfo info = BlockInfo.Decode(blockBits);
+ if (!info.IsValid)
+ {
+ return;
+ }
+
+ // Fused fast path for single-partition, non-dual-plane blocks
+ if (!info.IsVoidExtent && info.PartitionCount == 1 && !info.IsDualPlane)
+ {
+ FusedHdrBlockDecoder.DecompressBlockFusedHdr(blockBits, in info, footprint, buffer);
+ return;
+ }
+
+ // Fallback for void extent, multi-partition, dual plane
+ LogicalBlock? logicalBlock = LogicalBlock.UnpackLogicalBlock(footprint, blockBits, in info);
+ if (logicalBlock is null)
+ {
+ return;
+ }
+
+ const int channelsPerPixel = 4;
+ for (int row = 0; row < footprint.Height; row++)
+ {
+ for (int column = 0; column < footprint.Width; ++column)
+ {
+ int pixelOffset = (footprint.Width * row * channelsPerPixel) + (column * channelsPerPixel);
+ logicalBlock.WriteHdrPixel(column, row, buffer.Slice(pixelOffset, channelsPerPixel));
+ }
+ }
+ }
+
+ internal static Span DecompressImage(AstcFile file)
+ {
+ ArgumentNullException.ThrowIfNull(file);
+
+ return DecompressImage(file.Blocks, file.Width, file.Height, file.Footprint);
+ }
+
+ internal static Span DecompressImage(ReadOnlySpan astcData, int width, int height, FootprintType footprint)
+ {
+ Footprint footPrint = Footprint.FromFootprintType(footprint);
+
+ return DecompressImage(astcData, width, height, footPrint);
+ }
+
+ private static bool TryGetBlockLayout(
+ ReadOnlySpan astcData,
+ int width,
+ int height,
+ Footprint footprint,
+ out int blocksWide,
+ out int blocksHigh)
+ {
+ int blockWidth = footprint.Width;
+ int blockHeight = footprint.Height;
+ blocksWide = 0;
+ blocksHigh = 0;
+
+ if (blockWidth == 0 || blockHeight == 0 || width == 0 || height == 0)
+ {
+ return false;
+ }
+
+ blocksWide = (width + blockWidth - 1) / blockWidth;
+ if (blocksWide == 0)
+ {
+ return false;
+ }
+
+ blocksHigh = (height + blockHeight - 1) / blockHeight;
+ int expectedBlockCount = blocksWide * blocksHigh;
+ if (astcData.Length % PhysicalBlock.SizeInBytes != 0 || astcData.Length / PhysicalBlock.SizeInBytes != expectedBlockCount)
+ {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BiseEncodingMode.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BiseEncodingMode.cs
new file mode 100644
index 00000000..028efe0c
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BiseEncodingMode.cs
@@ -0,0 +1,18 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+
+///
+/// The encoding modes supported by BISE.
+///
+///
+/// Note that the values correspond to the number of symbols in each alphabet.
+///
+internal enum BiseEncodingMode
+{
+ Unknown = 0,
+ BitEncoding = 1,
+ TritEncoding = 3,
+ QuintEncoding = 5,
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceCodec.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceCodec.cs
new file mode 100644
index 00000000..64331f95
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceCodec.cs
@@ -0,0 +1,264 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+
+///
+/// The Bounded Integer Sequence Encoding (BISE) allows storage of character sequences using
+/// arbitrary alphabets of up to 256 symbols. Each alphabet size is encoded in the most
+/// space-efficient choice of bits, trits, and quints.
+///
+internal partial class BoundedIntegerSequenceCodec
+{
+ ///
+ /// The maximum number of bits needed to encode an ISE value.
+ ///
+ ///
+ /// The ASTC specification does not give a maximum number, however unquantized color
+ /// values have a maximum range of 255, meaning that we can't feasibly have more
+ /// than eight bits per value.
+ ///
+ private const int Log2MaxRangeForBits = 8;
+
+ ///
+ /// The number of bits used after each value to store the interleaved quint block.
+ ///
+ protected static readonly int[] InterleavedQuintBits = [3, 2, 2];
+
+ ///
+ /// The number of bits used after each value to store the interleaved trit block.
+ ///
+ protected static readonly int[] InterleavedTritBits = [2, 2, 1, 2, 1];
+
+ ///
+ /// Trit encodings for BISE blocks.
+ ///
+ ///
+ ///
+ /// These tables are used to decode the blocks of values encoded using the ASTC
+ /// integer sequence encoding. The theory is that five trits (values that can
+ /// take any number in the range [0, 2]) can take on a total of 3^5 = 243 total
+ /// values, which can be stored in eight bits. These eight bits are used to
+ /// decode the five trits based on the ASTC specification in Section C.2.12.
+ ///
+ ///
+ /// For simplicity, we have stored a look-up table here so that we don't need
+ /// to implement the decoding logic. Similarly, seven bits are used to decode
+ /// three quints.
+ ///
+ ///
+ protected static readonly int[][] TritEncodings =
+ [
+ [0, 0, 0, 0, 0], [1, 0, 0, 0, 0], [2, 0, 0, 0, 0], [0, 0, 2, 0, 0], [0, 1, 0, 0, 0], [1, 1, 0, 0, 0], [2, 1, 0, 0, 0], [1, 0, 2, 0, 0], [0, 2, 0, 0, 0],
+ [1, 2, 0, 0, 0], [2, 2, 0, 0, 0], [2, 0, 2, 0, 0], [0, 2, 2, 0, 0], [1, 2, 2, 0, 0], [2, 2, 2, 0, 0], [2, 0, 2, 0, 0], [0, 0, 1, 0, 0], [1, 0, 1, 0, 0],
+ [2, 0, 1, 0, 0], [0, 1, 2, 0, 0], [0, 1, 1, 0, 0], [1, 1, 1, 0, 0], [2, 1, 1, 0, 0], [1, 1, 2, 0, 0], [0, 2, 1, 0, 0], [1, 2, 1, 0, 0], [2, 2, 1, 0, 0],
+ [2, 1, 2, 0, 0], [0, 0, 0, 2, 2], [1, 0, 0, 2, 2], [2, 0, 0, 2, 2], [0, 0, 2, 2, 2], [0, 0, 0, 1, 0], [1, 0, 0, 1, 0], [2, 0, 0, 1, 0], [0, 0, 2, 1, 0],
+ [0, 1, 0, 1, 0], [1, 1, 0, 1, 0], [2, 1, 0, 1, 0], [1, 0, 2, 1, 0], [0, 2, 0, 1, 0], [1, 2, 0, 1, 0], [2, 2, 0, 1, 0], [2, 0, 2, 1, 0], [0, 2, 2, 1, 0],
+ [1, 2, 2, 1, 0], [2, 2, 2, 1, 0], [2, 0, 2, 1, 0], [0, 0, 1, 1, 0], [1, 0, 1, 1, 0], [2, 0, 1, 1, 0], [0, 1, 2, 1, 0], [0, 1, 1, 1, 0], [1, 1, 1, 1, 0],
+ [2, 1, 1, 1, 0], [1, 1, 2, 1, 0], [0, 2, 1, 1, 0], [1, 2, 1, 1, 0], [2, 2, 1, 1, 0], [2, 1, 2, 1, 0], [0, 1, 0, 2, 2], [1, 1, 0, 2, 2], [2, 1, 0, 2, 2],
+ [1, 0, 2, 2, 2], [0, 0, 0, 2, 0], [1, 0, 0, 2, 0], [2, 0, 0, 2, 0], [0, 0, 2, 2, 0], [0, 1, 0, 2, 0], [1, 1, 0, 2, 0], [2, 1, 0, 2, 0], [1, 0, 2, 2, 0],
+ [0, 2, 0, 2, 0], [1, 2, 0, 2, 0], [2, 2, 0, 2, 0], [2, 0, 2, 2, 0], [0, 2, 2, 2, 0], [1, 2, 2, 2, 0], [2, 2, 2, 2, 0], [2, 0, 2, 2, 0], [0, 0, 1, 2, 0],
+ [1, 0, 1, 2, 0], [2, 0, 1, 2, 0], [0, 1, 2, 2, 0], [0, 1, 1, 2, 0], [1, 1, 1, 2, 0], [2, 1, 1, 2, 0], [1, 1, 2, 2, 0], [0, 2, 1, 2, 0], [1, 2, 1, 2, 0],
+ [2, 2, 1, 2, 0], [2, 1, 2, 2, 0], [0, 2, 0, 2, 2], [1, 2, 0, 2, 2], [2, 2, 0, 2, 2], [2, 0, 2, 2, 2], [0, 0, 0, 0, 2], [1, 0, 0, 0, 2], [2, 0, 0, 0, 2],
+ [0, 0, 2, 0, 2], [0, 1, 0, 0, 2], [1, 1, 0, 0, 2], [2, 1, 0, 0, 2], [1, 0, 2, 0, 2], [0, 2, 0, 0, 2], [1, 2, 0, 0, 2], [2, 2, 0, 0, 2], [2, 0, 2, 0, 2],
+ [0, 2, 2, 0, 2], [1, 2, 2, 0, 2], [2, 2, 2, 0, 2], [2, 0, 2, 0, 2], [0, 0, 1, 0, 2], [1, 0, 1, 0, 2], [2, 0, 1, 0, 2], [0, 1, 2, 0, 2], [0, 1, 1, 0, 2],
+ [1, 1, 1, 0, 2], [2, 1, 1, 0, 2], [1, 1, 2, 0, 2], [0, 2, 1, 0, 2], [1, 2, 1, 0, 2], [2, 2, 1, 0, 2], [2, 1, 2, 0, 2], [0, 2, 2, 2, 2], [1, 2, 2, 2, 2],
+ [2, 2, 2, 2, 2], [2, 0, 2, 2, 2], [0, 0, 0, 0, 1], [1, 0, 0, 0, 1], [2, 0, 0, 0, 1], [0, 0, 2, 0, 1], [0, 1, 0, 0, 1], [1, 1, 0, 0, 1], [2, 1, 0, 0, 1],
+ [1, 0, 2, 0, 1], [0, 2, 0, 0, 1], [1, 2, 0, 0, 1], [2, 2, 0, 0, 1], [2, 0, 2, 0, 1], [0, 2, 2, 0, 1], [1, 2, 2, 0, 1], [2, 2, 2, 0, 1], [2, 0, 2, 0, 1],
+ [0, 0, 1, 0, 1], [1, 0, 1, 0, 1], [2, 0, 1, 0, 1], [0, 1, 2, 0, 1], [0, 1, 1, 0, 1], [1, 1, 1, 0, 1], [2, 1, 1, 0, 1], [1, 1, 2, 0, 1], [0, 2, 1, 0, 1],
+ [1, 2, 1, 0, 1], [2, 2, 1, 0, 1], [2, 1, 2, 0, 1], [0, 0, 1, 2, 2], [1, 0, 1, 2, 2], [2, 0, 1, 2, 2], [0, 1, 2, 2, 2], [0, 0, 0, 1, 1], [1, 0, 0, 1, 1],
+ [2, 0, 0, 1, 1], [0, 0, 2, 1, 1], [0, 1, 0, 1, 1], [1, 1, 0, 1, 1], [2, 1, 0, 1, 1], [1, 0, 2, 1, 1], [0, 2, 0, 1, 1], [1, 2, 0, 1, 1], [2, 2, 0, 1, 1],
+ [2, 0, 2, 1, 1], [0, 2, 2, 1, 1], [1, 2, 2, 1, 1], [2, 2, 2, 1, 1], [2, 0, 2, 1, 1], [0, 0, 1, 1, 1], [1, 0, 1, 1, 1], [2, 0, 1, 1, 1], [0, 1, 2, 1, 1],
+ [0, 1, 1, 1, 1], [1, 1, 1, 1, 1], [2, 1, 1, 1, 1], [1, 1, 2, 1, 1], [0, 2, 1, 1, 1], [1, 2, 1, 1, 1], [2, 2, 1, 1, 1], [2, 1, 2, 1, 1], [0, 1, 1, 2, 2],
+ [1, 1, 1, 2, 2], [2, 1, 1, 2, 2], [1, 1, 2, 2, 2], [0, 0, 0, 2, 1], [1, 0, 0, 2, 1], [2, 0, 0, 2, 1], [0, 0, 2, 2, 1], [0, 1, 0, 2, 1], [1, 1, 0, 2, 1],
+ [2, 1, 0, 2, 1], [1, 0, 2, 2, 1], [0, 2, 0, 2, 1], [1, 2, 0, 2, 1], [2, 2, 0, 2, 1], [2, 0, 2, 2, 1], [0, 2, 2, 2, 1], [1, 2, 2, 2, 1], [2, 2, 2, 2, 1],
+ [2, 0, 2, 2, 1], [0, 0, 1, 2, 1], [1, 0, 1, 2, 1], [2, 0, 1, 2, 1], [0, 1, 2, 2, 1], [0, 1, 1, 2, 1], [1, 1, 1, 2, 1], [2, 1, 1, 2, 1], [1, 1, 2, 2, 1],
+ [0, 2, 1, 2, 1], [1, 2, 1, 2, 1], [2, 2, 1, 2, 1], [2, 1, 2, 2, 1], [0, 2, 1, 2, 2], [1, 2, 1, 2, 2], [2, 2, 1, 2, 2], [2, 1, 2, 2, 2], [0, 0, 0, 1, 2],
+ [1, 0, 0, 1, 2], [2, 0, 0, 1, 2], [0, 0, 2, 1, 2], [0, 1, 0, 1, 2], [1, 1, 0, 1, 2], [2, 1, 0, 1, 2], [1, 0, 2, 1, 2], [0, 2, 0, 1, 2], [1, 2, 0, 1, 2],
+ [2, 2, 0, 1, 2], [2, 0, 2, 1, 2], [0, 2, 2, 1, 2], [1, 2, 2, 1, 2], [2, 2, 2, 1, 2], [2, 0, 2, 1, 2], [0, 0, 1, 1, 2], [1, 0, 1, 1, 2], [2, 0, 1, 1, 2],
+ [0, 1, 2, 1, 2], [0, 1, 1, 1, 2], [1, 1, 1, 1, 2], [2, 1, 1, 1, 2], [1, 1, 2, 1, 2], [0, 2, 1, 1, 2], [1, 2, 1, 1, 2], [2, 2, 1, 1, 2], [2, 1, 2, 1, 2],
+ [0, 2, 2, 2, 2], [1, 2, 2, 2, 2], [2, 2, 2, 2, 2], [2, 1, 2, 2, 2]
+ ];
+
+ ///
+ /// Quint encodings for BISE blocks.
+ ///
+ ///
+ /// See for more details.
+ ///
+ protected static readonly int[][] QuintEncodings =
+ [
+ [0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0], [4, 0, 0], [0, 4, 0], [4, 4, 0], [4, 4, 4], [0, 1, 0], [1, 1, 0], [2, 1, 0], [3, 1, 0], [4, 1, 0],
+ [1, 4, 0], [4, 4, 1], [4, 4, 4], [0, 2, 0], [1, 2, 0], [2, 2, 0], [3, 2, 0], [4, 2, 0], [2, 4, 0], [4, 4, 2], [4, 4, 4], [0, 3, 0], [1, 3, 0],
+ [2, 3, 0], [3, 3, 0], [4, 3, 0], [3, 4, 0], [4, 4, 3], [4, 4, 4], [0, 0, 1], [1, 0, 1], [2, 0, 1], [3, 0, 1], [4, 0, 1], [0, 4, 1], [4, 0, 4],
+ [0, 4, 4], [0, 1, 1], [1, 1, 1], [2, 1, 1], [3, 1, 1], [4, 1, 1], [1, 4, 1], [4, 1, 4], [1, 4, 4], [0, 2, 1], [1, 2, 1], [2, 2, 1], [3, 2, 1],
+ [4, 2, 1], [2, 4, 1], [4, 2, 4], [2, 4, 4], [0, 3, 1], [1, 3, 1], [2, 3, 1], [3, 3, 1], [4, 3, 1], [3, 4, 1], [4, 3, 4], [3, 4, 4], [0, 0, 2],
+ [1, 0, 2], [2, 0, 2], [3, 0, 2], [4, 0, 2], [0, 4, 2], [2, 0, 4], [3, 0, 4], [0, 1, 2], [1, 1, 2], [2, 1, 2], [3, 1, 2], [4, 1, 2], [1, 4, 2],
+ [2, 1, 4], [3, 1, 4], [0, 2, 2], [1, 2, 2], [2, 2, 2], [3, 2, 2], [4, 2, 2], [2, 4, 2], [2, 2, 4], [3, 2, 4], [0, 3, 2], [1, 3, 2], [2, 3, 2],
+ [3, 3, 2], [4, 3, 2], [3, 4, 2], [2, 3, 4], [3, 3, 4], [0, 0, 3], [1, 0, 3], [2, 0, 3], [3, 0, 3], [4, 0, 3], [0, 4, 3], [0, 0, 4], [1, 0, 4],
+ [0, 1, 3], [1, 1, 3], [2, 1, 3], [3, 1, 3], [4, 1, 3], [1, 4, 3], [0, 1, 4], [1, 1, 4], [0, 2, 3], [1, 2, 3], [2, 2, 3], [3, 2, 3], [4, 2, 3],
+ [2, 4, 3], [0, 2, 4], [1, 2, 4], [0, 3, 3], [1, 3, 3], [2, 3, 3], [3, 3, 3], [4, 3, 3], [3, 4, 3], [0, 3, 4], [1, 3, 4]
+ ];
+
+ ///
+ /// The maximum ranges for BISE encoding.
+ ///
+ ///
+ /// These are the numbers between 1 and
+ /// that can be represented exactly as a number in the ranges
+ /// [0, 2^k), [0, 3 * 2^k), and [0, 5 * 2^k).
+ ///
+ internal static readonly int[] MaxRanges = [1, 2, 3, 4, 5, 7, 9, 11, 15, 19, 23, 31, 39, 47, 63, 79, 95, 127, 159, 191, 255];
+
+ // Flat encoding tables: eliminates jagged array indirection
+ protected static readonly int[] FlatTritEncodings = FlattenEncodings(TritEncodings, 5);
+ protected static readonly int[] FlatQuintEncodings = FlattenEncodings(QuintEncodings, 3);
+
+ private static readonly (BiseEncodingMode Mode, int BitCount)[] PackingModeCache = InitPackingModeCache();
+
+ ///
+ /// Initializes a new instance of the class.
+ /// operate on sequences of integers and produce bit patterns that pack the
+ /// integers based on the encoding scheme specified in the ASTC specification
+ /// Section C.2.12.
+ ///
+ ///
+ ///
+ /// The resulting bit pattern is a sequence of encoded blocks.
+ /// All blocks in a sequence are one of the following encodings:
+ ///
+ ///
+ /// - Bit encoding: one encoded value of the form 2^k
+ /// - Trit encoding: five encoded values of the form 3*2^k
+ /// - Quint encoding: three encoded values of the form 5*2^k
+ ///
+ /// The layouts of each block are designed such that the blocks can be truncated
+ /// during encoding in order to support variable length input sequences (i.e. a
+ /// sequence of values that are encoded using trit encoded blocks does not
+ /// need to have a multiple-of-five length).
+ ///
+ /// Creates a decoder that decodes values within [0, ] (inclusive).
+ protected BoundedIntegerSequenceCodec(int range)
+ {
+ (BiseEncodingMode encodingMode, int bitCount) = GetPackingModeBitCount(range);
+ this.Encoding = encodingMode;
+ this.BitCount = bitCount;
+ }
+
+ protected BiseEncodingMode Encoding { get; }
+
+ protected int BitCount { get; }
+
+ ///
+ /// The number of bits needed to encode the given number of values with respect to the
+ /// number of trits, quints, and bits specified by .
+ ///
+ public static (BiseEncodingMode Mode, int BitCount) GetPackingModeBitCount(int range)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(range, 0);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(range, 1 << Log2MaxRangeForBits);
+
+ return PackingModeCache[range];
+ }
+
+ ///
+ /// Returns the overall bit count for a range of values encoded
+ ///
+ public static int GetBitCount(BiseEncodingMode encodingMode, int valuesCount, int bitCount)
+ {
+ int encodingBitCount = encodingMode switch
+ {
+ BiseEncodingMode.TritEncoding => ((valuesCount * 8) + 4) / 5,
+ BiseEncodingMode.QuintEncoding => ((valuesCount * 7) + 2) / 3,
+ BiseEncodingMode.BitEncoding => 0,
+ _ => throw new ArgumentOutOfRangeException(nameof(encodingMode), "Invalid encoding mode"),
+ };
+ int baseBitCount = valuesCount * bitCount;
+
+ return encodingBitCount + baseBitCount;
+ }
+
+ ///
+ /// The number of bits needed to encode a given number of values within the range [0, ] (inclusive).
+ ///
+ public static int GetBitCountForRange(int valuesCount, int range)
+ {
+ (BiseEncodingMode mode, int bitCount) = GetPackingModeBitCount(range);
+
+ return GetBitCount(mode, valuesCount, bitCount);
+ }
+
+ ///
+ /// The size of a single ISE block in bits
+ ///
+ protected int GetEncodedBlockSize()
+ {
+ (int blockSize, int extraBlockSize) = this.Encoding switch
+ {
+ BiseEncodingMode.TritEncoding => (5, 8),
+ BiseEncodingMode.QuintEncoding => (3, 7),
+ BiseEncodingMode.BitEncoding => (1, 0),
+ _ => (0, 0),
+ };
+
+ return extraBlockSize + (blockSize * this.BitCount);
+ }
+
+ private static int[] FlattenEncodings(int[][] jagged, int stride)
+ {
+ int[] flat = new int[jagged.Length * stride];
+ for (int i = 0; i < jagged.Length; i++)
+ {
+ for (int j = 0; j < stride; j++)
+ {
+ flat[(i * stride) + j] = jagged[i][j];
+ }
+ }
+
+ return flat;
+ }
+
+ private static (BiseEncodingMode, int)[] InitPackingModeCache()
+ {
+ (BiseEncodingMode, int)[] cache = new (BiseEncodingMode, int)[1 << Log2MaxRangeForBits];
+
+ // Precompute for all valid ranges [1, 255]
+ for (int range = 1; range < cache.Length; range++)
+ {
+ int index = -1;
+ for (int i = 0; i < MaxRanges.Length; i++)
+ {
+ if (MaxRanges[i] >= range)
+ {
+ index = i;
+ break;
+ }
+ }
+
+ int maxValue = index < 0
+ ? MaxRanges[^1] + 1
+ : MaxRanges[index] + 1;
+
+ // Check QuintEncoding (5), TritEncoding (3), BitEncoding (1) in descending order
+ BiseEncodingMode encodingMode = BiseEncodingMode.Unknown;
+ ReadOnlySpan modes = [BiseEncodingMode.QuintEncoding, BiseEncodingMode.TritEncoding, BiseEncodingMode.BitEncoding];
+ foreach (BiseEncodingMode em in modes)
+ {
+ if (maxValue % (int)em == 0 && int.IsPow2(maxValue / (int)em))
+ {
+ encodingMode = em;
+ break;
+ }
+ }
+
+ if (encodingMode == BiseEncodingMode.Unknown)
+ {
+ throw new InvalidOperationException($"Invalid range for BISE encoding: {range}");
+ }
+
+ cache[range] = (encodingMode, int.Log2(maxValue / (int)encodingMode));
+ }
+
+ return cache;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceDecoder.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceDecoder.cs
new file mode 100644
index 00000000..1b319c97
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceDecoder.cs
@@ -0,0 +1,159 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+
+internal sealed class BoundedIntegerSequenceDecoder : BoundedIntegerSequenceCodec
+{
+ private static readonly BoundedIntegerSequenceDecoder?[] Cache = new BoundedIntegerSequenceDecoder?[256];
+
+ public BoundedIntegerSequenceDecoder(int range)
+ : base(range)
+ {
+ }
+
+ public static BoundedIntegerSequenceDecoder GetCached(int range)
+ {
+ BoundedIntegerSequenceDecoder? decoder = Cache[range];
+ if (decoder is null)
+ {
+ decoder = new BoundedIntegerSequenceDecoder(range);
+ Cache[range] = decoder;
+ }
+
+ return decoder;
+ }
+
+ ///
+ /// Decode a sequence of bounded integers into a caller-provided span.
+ ///
+ /// The number of values to decode.
+ /// The source of values to decode from.
+ /// The span to write decoded values into.
+ /// Thrown when the encoded block size is too large.
+ /// Thrown when there are not enough bits to decode.
+ public void Decode(int valuesCount, ref BitStream bitSource, Span result)
+ {
+ int totalBitCount = GetBitCount(this.Encoding, valuesCount, this.BitCount);
+ int bitsPerBlock = this.GetEncodedBlockSize();
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(bitsPerBlock, 64);
+
+ Span blockResult = stackalloc int[5];
+ int resultIndex = 0;
+ int bitsRemaining = totalBitCount;
+
+ while (bitsRemaining > 0)
+ {
+ int bitsToRead = Math.Min(bitsRemaining, bitsPerBlock);
+ if (!bitSource.TryGetBits(bitsToRead, out ulong blockBits))
+ {
+ throw new InvalidOperationException("Not enough bits in BitStream to decode BISE block");
+ }
+
+ if (this.Encoding == BiseEncodingMode.BitEncoding)
+ {
+ if (resultIndex < valuesCount)
+ {
+ result[resultIndex++] = (int)blockBits;
+ }
+ }
+ else
+ {
+ int decoded = DecodeISEBlock(this.Encoding, blockBits, this.BitCount, blockResult);
+ for (int i = 0; i < decoded && resultIndex < valuesCount; ++i)
+ {
+ result[resultIndex++] = blockResult[i];
+ }
+ }
+
+ bitsRemaining -= bitsPerBlock;
+ }
+
+ if (resultIndex < valuesCount)
+ {
+ throw new InvalidOperationException("Decoded fewer values than expected from BISE block");
+ }
+ }
+
+ ///
+ /// Decode a sequence of bounded integers. The number of bits read is dependent on the number
+ /// of bits required to encode based on the calculation provided
+ /// in Section C.2.22 of the ASTC specification.
+ ///
+ /// The number of values to decode.
+ /// The source of values to decode from.
+ /// The decoded values. The collection always contains exactly elements.
+ /// Thrown when the encoded block size is too large.
+ /// Thrown when there are not enough bits to decode.
+ public int[] Decode(int valuesCount, ref BitStream bitSource)
+ {
+ int[] result = new int[valuesCount];
+ this.Decode(valuesCount, ref bitSource, result);
+ return result;
+ }
+
+ ///
+ /// Decode a trit/quint block into a caller-provided span.
+ /// Returns the number of values written.
+ /// Uses direct bit extraction (no BitStream) and flat encoding tables.
+ ///
+ public static int DecodeISEBlock(BiseEncodingMode mode, ulong encodedBlock, int encodedBitCount, Span result)
+ {
+ ulong mantissaMask = (1UL << encodedBitCount) - 1;
+
+ if (mode == BiseEncodingMode.TritEncoding)
+ {
+ // 5 values, interleaved bits = [2, 2, 1, 2, 1] = 8 bits total
+ int bitPosition = 0;
+ int mantissa0 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ bitPosition += encodedBitCount;
+ ulong encodedTrits = (encodedBlock >> bitPosition) & 0x3;
+ bitPosition += 2;
+ int mantissa1 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ bitPosition += encodedBitCount;
+ encodedTrits |= ((encodedBlock >> bitPosition) & 0x3) << 2;
+ bitPosition += 2;
+ int mantissa2 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ bitPosition += encodedBitCount;
+ encodedTrits |= ((encodedBlock >> bitPosition) & 0x1) << 4;
+ bitPosition += 1;
+ int mantissa3 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ bitPosition += encodedBitCount;
+ encodedTrits |= ((encodedBlock >> bitPosition) & 0x3) << 5;
+ bitPosition += 2;
+ int mantissa4 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ encodedTrits |= ((encodedBlock >> (bitPosition + encodedBitCount)) & 0x1) << 7;
+
+ int tritTableBase = (int)encodedTrits * 5;
+ result[0] = (FlatTritEncodings[tritTableBase] << encodedBitCount) | mantissa0;
+ result[1] = (FlatTritEncodings[tritTableBase + 1] << encodedBitCount) | mantissa1;
+ result[2] = (FlatTritEncodings[tritTableBase + 2] << encodedBitCount) | mantissa2;
+ result[3] = (FlatTritEncodings[tritTableBase + 3] << encodedBitCount) | mantissa3;
+ result[4] = (FlatTritEncodings[tritTableBase + 4] << encodedBitCount) | mantissa4;
+ return 5;
+ }
+ else
+ {
+ // 3 values, interleaved bits = [3, 2, 2] = 7 bits total
+ int bitPosition = 0;
+ int mantissa0 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ bitPosition += encodedBitCount;
+ ulong encodedQuints = (encodedBlock >> bitPosition) & 0x7;
+ bitPosition += 3;
+ int mantissa1 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ bitPosition += encodedBitCount;
+ encodedQuints |= ((encodedBlock >> bitPosition) & 0x3) << 3;
+ bitPosition += 2;
+ int mantissa2 = (int)((encodedBlock >> bitPosition) & mantissaMask);
+ encodedQuints |= ((encodedBlock >> (bitPosition + encodedBitCount)) & 0x3) << 5;
+
+ int quintTableBase = (int)encodedQuints * 3;
+ result[0] = (FlatQuintEncodings[quintTableBase] << encodedBitCount) | mantissa0;
+ result[1] = (FlatQuintEncodings[quintTableBase + 1] << encodedBitCount) | mantissa1;
+ result[2] = (FlatQuintEncodings[quintTableBase + 2] << encodedBitCount) | mantissa2;
+ return 3;
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceEncoder.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceEncoder.cs
new file mode 100644
index 00000000..fe10d796
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/BoundedIntegerSequenceEncoder.cs
@@ -0,0 +1,168 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+
+internal sealed class BoundedIntegerSequenceEncoder : BoundedIntegerSequenceCodec
+{
+ private readonly List values = [];
+
+ public BoundedIntegerSequenceEncoder(int range)
+ : base(range)
+ {
+ }
+
+ ///
+ /// Adds a value to the encoding sequence.
+ ///
+ public void AddValue(int val) => this.values.Add(val);
+
+ ///
+ /// Encodes and writes the stored values encoding to the sink. Repeated calls will produce the same result.
+ ///
+ public void Encode(ref BitStream bitSink)
+ {
+ int totalBitCount = GetBitCount(this.Encoding, this.values.Count, this.BitCount);
+
+ int index = 0;
+ int bitsWrittenCount = 0;
+ while (index < this.values.Count)
+ {
+ switch (this.Encoding)
+ {
+ case BiseEncodingMode.TritEncoding:
+ List trits = [];
+ for (int i = 0; i < 5; ++i)
+ {
+ if (index < this.values.Count)
+ {
+ trits.Add(this.values[index++]);
+ }
+ else
+ {
+ trits.Add(0);
+ }
+ }
+
+ EncodeISEBlock(trits, this.BitCount, ref bitSink, ref bitsWrittenCount, totalBitCount);
+ break;
+ case BiseEncodingMode.QuintEncoding:
+ List quints = [];
+ for (int i = 0; i < 3; ++i)
+ {
+ int value = index < this.values.Count
+ ? this.values[index++]
+ : 0;
+ quints.Add(value);
+ }
+
+ EncodeISEBlock(quints, this.BitCount, ref bitSink, ref bitsWrittenCount, totalBitCount);
+ break;
+ case BiseEncodingMode.BitEncoding:
+ bitSink.PutBits((uint)this.values[index++], this.GetEncodedBlockSize());
+ break;
+ }
+ }
+ }
+
+ ///
+ /// Clear the stored values.
+ ///
+ public void Reset() => this.values.Clear();
+
+ private static void EncodeISEBlock(List values, int bitsPerValue, ref BitStream bitSink, ref int bitsWritten, int totalBitCount)
+ where T : unmanaged
+ {
+ int valueCount = values.Count;
+ int valueRange = (valueCount == 3) ? 5 : 3;
+ int bitsPerBlock = (valueRange == 5) ? 7 : 8;
+ int[] interleavedBits = (valueRange == 5)
+ ? InterleavedQuintBits
+ : InterleavedTritBits;
+
+ int[] nonBitComponents = new int[valueCount];
+ int[] bitComponents = new int[valueCount];
+ for (int i = 0; i < valueCount; ++i)
+ {
+ bitComponents[i] = values[i] & ((1 << bitsPerValue) - 1);
+ nonBitComponents[i] = values[i] >> bitsPerValue;
+ }
+
+ // Determine how many interleaved bits for this block given the global
+ // totalBitCount and how many bits have already been written.
+ int tempBitsAdded = bitsWritten;
+ int encodedBitCount = 0;
+ for (int i = 0; i < valueCount; ++i)
+ {
+ tempBitsAdded += bitsPerValue;
+ if (tempBitsAdded >= totalBitCount)
+ {
+ break;
+ }
+
+ encodedBitCount += interleavedBits[i];
+ tempBitsAdded += interleavedBits[i];
+ if (tempBitsAdded >= totalBitCount)
+ {
+ break;
+ }
+ }
+
+ int nonBitEncoding = -1;
+ for (int j = (1 << encodedBitCount) - 1; j >= 0; --j)
+ {
+ bool matches = true;
+ for (int i = 0; i < valueCount; ++i)
+ {
+ if (valueRange == 5)
+ {
+ if (QuintEncodings[j][i] != nonBitComponents[i])
+ {
+ matches = false;
+ break;
+ }
+ }
+ else
+ {
+ if (TritEncodings[j][i] != nonBitComponents[i])
+ {
+ matches = false;
+ break;
+ }
+ }
+ }
+
+ if (matches)
+ {
+ nonBitEncoding = j;
+ break;
+ }
+ }
+
+ if (nonBitEncoding < 0)
+ {
+ throw new InvalidOperationException();
+ }
+
+ int nonBitEncodingCopy = nonBitEncoding;
+ for (int i = 0; i < valueCount; ++i)
+ {
+ if (bitsWritten + bitsPerValue <= totalBitCount)
+ {
+ bitSink.PutBits((uint)bitComponents[i], bitsPerValue);
+ bitsWritten += bitsPerValue;
+ }
+
+ int interleavedBitCount = interleavedBits[i];
+ int interleavedBitsValue = nonBitEncodingCopy & ((1 << interleavedBitCount) - 1);
+ if (bitsWritten + interleavedBitCount <= totalBitCount)
+ {
+ bitSink.PutBits((uint)interleavedBitsValue, interleavedBitCount);
+ bitsWritten += interleavedBitCount;
+ nonBitEncodingCopy >>= interleavedBitCount;
+ }
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/BitQuantizationMap.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/BitQuantizationMap.cs
new file mode 100644
index 00000000..5b5b3b59
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/BitQuantizationMap.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+
+internal sealed class BitQuantizationMap : QuantizationMap
+{
+ // TotalUnquantizedBits is 8 for endpoint values and 6 for weights
+ public BitQuantizationMap(int range, int totalUnquantizedBits)
+ {
+ // ensure range+1 is power of two
+ ArgumentOutOfRangeException.ThrowIfNotEqual(CountOnes(range + 1), 1);
+
+ int bitCount = Log2Floor(range + 1);
+
+ for (int bits = 0; bits <= range; bits++)
+ {
+ int unquantized = bits;
+ int unquantizedBitCount = bitCount;
+ while (unquantizedBitCount < totalUnquantizedBits)
+ {
+ int destinationShiftUp = Math.Min(bitCount, totalUnquantizedBits - unquantizedBitCount);
+ int sourceShiftDown = bitCount - destinationShiftUp;
+ unquantized <<= destinationShiftUp;
+ unquantized |= bits >> sourceShiftDown;
+ unquantizedBitCount += destinationShiftUp;
+ }
+
+ if (unquantizedBitCount != totalUnquantizedBits)
+ {
+ throw new InvalidOperationException();
+ }
+
+ this.UnquantizationMapBuilder.Add(unquantized);
+
+ if (bits > 0)
+ {
+ int previousUnquantized = this.UnquantizationMapBuilder[bits - 1];
+ while (this.QuantizationMapBuilder.Count <= (previousUnquantized + unquantized) / 2)
+ {
+ this.QuantizationMapBuilder.Add(bits - 1);
+ }
+ }
+
+ while (this.QuantizationMapBuilder.Count <= unquantized)
+ {
+ this.QuantizationMapBuilder.Add(bits);
+ }
+ }
+
+ this.Freeze();
+ }
+
+ private static int CountOnes(int value)
+ {
+ int count = 0;
+ while (value != 0)
+ {
+ count += value & 1;
+ value >>= 1;
+ }
+
+ return count;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/Quantization.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/Quantization.cs
new file mode 100644
index 00000000..1476cff8
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/Quantization.cs
@@ -0,0 +1,245 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+
+internal static class Quantization
+{
+ public const int EndpointRangeMinValue = 5;
+ public const int WeightRangeMaxValue = 31;
+
+ private static readonly SortedDictionary EndpointMaps = InitEndpointMaps();
+ private static readonly SortedDictionary WeightMaps = InitWeightMaps();
+
+ // Flat lookup tables indexed by range value for O(1) access.
+ // Each slot maps to the QuantizationMap for the greatest supported range <= that index.
+ private static readonly QuantizationMap?[] EndpointMapByRange = InitEndpointMapFlat();
+ private static readonly QuantizationMap?[] WeightMapByRange = InitWeightMapFlat();
+
+ // Pre-computed flat tables for weight unquantization: entry[quantizedValue] = final unquantized weight.
+ // Includes the dq > 32 -> dq + 1 adjustment. Indexed by weight range.
+ // Valid ranges: 1, 2, 3, 4, 5, 7, 9, 11, 15, 19, 23, 31
+ private static readonly int[]?[] UnquantizeWeightsFlat = InitializeUnquantizeWeightsFlat();
+
+ // Pre-computed flat tables for endpoint unquantization.
+ // Indexed by range value. Valid ranges: 5, 7, 9, 11, 15, 19, 23, 31, 39, 47, 63, 79, 95, 127, 159, 191, 255
+ private static readonly int[]?[] UnquantizeEndpointsFlat = InitializeUnquantizeEndpointsFlat();
+
+ public static int QuantizeCEValueToRange(int value, int rangeMaxValue)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rangeMaxValue, EndpointRangeMinValue);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(rangeMaxValue, byte.MaxValue);
+ ArgumentOutOfRangeException.ThrowIfLessThan(value, 0);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(value, byte.MaxValue);
+
+ QuantizationMap? map = GetQuantMapForValueRange(rangeMaxValue);
+ return map != null ? map.Quantize(value) : 0;
+ }
+
+ public static int UnquantizeCEValueFromRange(int value, int rangeMaxValue)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rangeMaxValue, EndpointRangeMinValue);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(rangeMaxValue, byte.MaxValue);
+ ArgumentOutOfRangeException.ThrowIfLessThan(value, 0);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(value, rangeMaxValue);
+
+ QuantizationMap? map = GetQuantMapForValueRange(rangeMaxValue);
+ return map != null ? map.Unquantize(value) : 0;
+ }
+
+ public static int QuantizeWeightToRange(int weight, int rangeMaxValue)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rangeMaxValue, 1);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(rangeMaxValue, WeightRangeMaxValue);
+ ArgumentOutOfRangeException.ThrowIfLessThan(weight, 0);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(weight, 64);
+
+ if (weight > 33)
+ {
+ weight -= 1;
+ }
+
+ QuantizationMap? map = GetQuantMapForWeightRange(rangeMaxValue);
+ return map != null ? map.Quantize(weight) : 0;
+ }
+
+ public static int UnquantizeWeightFromRange(int weight, int rangeMaxValue)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(rangeMaxValue, 1);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(rangeMaxValue, WeightRangeMaxValue);
+ ArgumentOutOfRangeException.ThrowIfLessThan(weight, 0);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(weight, rangeMaxValue);
+
+ QuantizationMap? map = GetQuantMapForWeightRange(rangeMaxValue);
+ int dequantized = map != null ? map.Unquantize(weight) : 0;
+ if (dequantized > 32)
+ {
+ dequantized += 1;
+ }
+
+ return dequantized;
+ }
+
+ ///
+ /// Batch unquantize: uses pre-computed flat table for O(1) lookup per value.
+ /// No per-call validation, no conditional branch per weight.
+ ///
+ internal static void UnquantizeWeightsBatch(Span weights, int count, int range)
+ {
+ int[]? table = UnquantizeWeightsFlat[range];
+ if (table == null)
+ {
+ return;
+ }
+
+ for (int i = 0; i < count; i++)
+ {
+ weights[i] = table[weights[i]];
+ }
+ }
+
+ ///
+ /// Batch unquantize color endpoint values: uses pre-computed flat table.
+ /// No per-call validation, single array lookup per value.
+ ///
+ internal static void UnquantizeCEValuesBatch(Span values, int count, int rangeMaxValue)
+ {
+ int[]? table = UnquantizeEndpointsFlat[rangeMaxValue];
+ if (table == null)
+ {
+ return;
+ }
+
+ for (int i = 0; i < count; i++)
+ {
+ values[i] = table[values[i]];
+ }
+ }
+
+ private static SortedDictionary InitEndpointMaps()
+ {
+ SortedDictionary d = new()
+ {
+ { 5, new TritQuantizationMap(5, TritQuantizationMap.GetUnquantizedValue) },
+ { 7, new BitQuantizationMap(7, 8) },
+ { 9, new QuintQuantizationMap(9, QuintQuantizationMap.GetUnquantizedValue) },
+ { 11, new TritQuantizationMap(11, TritQuantizationMap.GetUnquantizedValue) },
+ { 15, new BitQuantizationMap(15, 8) },
+ { 19, new QuintQuantizationMap(19, QuintQuantizationMap.GetUnquantizedValue) },
+ { 23, new TritQuantizationMap(23, TritQuantizationMap.GetUnquantizedValue) },
+ { 31, new BitQuantizationMap(31, 8) },
+ { 39, new QuintQuantizationMap(39, QuintQuantizationMap.GetUnquantizedValue) },
+ { 47, new TritQuantizationMap(47, TritQuantizationMap.GetUnquantizedValue) },
+ { 63, new BitQuantizationMap(63, 8) },
+ { 79, new QuintQuantizationMap(79, QuintQuantizationMap.GetUnquantizedValue) },
+ { 95, new TritQuantizationMap(95, TritQuantizationMap.GetUnquantizedValue) },
+ { 127, new BitQuantizationMap(127, 8) },
+ { 159, new QuintQuantizationMap(159, QuintQuantizationMap.GetUnquantizedValue) },
+ { 191, new TritQuantizationMap(191, TritQuantizationMap.GetUnquantizedValue) },
+ { 255, new BitQuantizationMap(255, 8) }
+ };
+ return d;
+ }
+
+ private static SortedDictionary InitWeightMaps()
+ {
+ SortedDictionary d = new()
+ {
+ { 1, new BitQuantizationMap(1, 6) },
+ { 2, new TritQuantizationMap(2, TritQuantizationMap.GetUnquantizedWeight) },
+ { 3, new BitQuantizationMap(3, 6) },
+ { 4, new QuintQuantizationMap(4, QuintQuantizationMap.GetUnquantizedWeight) },
+ { 5, new TritQuantizationMap(5, TritQuantizationMap.GetUnquantizedWeight) },
+ { 7, new BitQuantizationMap(7, 6) },
+ { 9, new QuintQuantizationMap(9, QuintQuantizationMap.GetUnquantizedWeight) },
+ { 11, new TritQuantizationMap(11, TritQuantizationMap.GetUnquantizedWeight) },
+ { 15, new BitQuantizationMap(15, 6) },
+ { 19, new QuintQuantizationMap(19, QuintQuantizationMap.GetUnquantizedWeight) },
+ { 23, new TritQuantizationMap(23, TritQuantizationMap.GetUnquantizedWeight) },
+ { 31, new BitQuantizationMap(31, 6) }
+ };
+ return d;
+ }
+
+ private static QuantizationMap?[] BuildFlatLookup(SortedDictionary maps, int size)
+ {
+ QuantizationMap?[] flat = new QuantizationMap?[size];
+ QuantizationMap? current = null;
+ for (int i = 0; i < size; i++)
+ {
+ if (maps.TryGetValue(i, out QuantizationMap? map))
+ {
+ current = map;
+ }
+
+ flat[i] = current;
+ }
+
+ return flat;
+ }
+
+ private static QuantizationMap?[] InitEndpointMapFlat()
+ => BuildFlatLookup(InitEndpointMaps(), 256);
+
+ private static QuantizationMap?[] InitWeightMapFlat()
+ => BuildFlatLookup(InitWeightMaps(), 32);
+
+ private static QuantizationMap? GetQuantMapForValueRange(int r)
+ {
+ if ((uint)r >= (uint)EndpointMapByRange.Length)
+ {
+ return null;
+ }
+
+ return EndpointMapByRange[r];
+ }
+
+ private static QuantizationMap? GetQuantMapForWeightRange(int r)
+ {
+ if ((uint)r >= (uint)WeightMapByRange.Length)
+ {
+ return null;
+ }
+
+ return WeightMapByRange[r];
+ }
+
+ private static int[]?[] InitializeUnquantizeWeightsFlat()
+ {
+ int[]?[] tables = new int[]?[WeightRangeMaxValue + 1];
+ foreach (KeyValuePair kvp in WeightMaps)
+ {
+ int range = kvp.Key;
+ QuantizationMap map = kvp.Value;
+ int[] table = new int[range + 1];
+ for (int i = 0; i <= range; i++)
+ {
+ int dequantized = map.Unquantize(i);
+ table[i] = dequantized > 32 ? dequantized + 1 : dequantized;
+ }
+
+ tables[range] = table;
+ }
+
+ return tables;
+ }
+
+ private static int[]?[] InitializeUnquantizeEndpointsFlat()
+ {
+ int[]?[] tables = new int[]?[256];
+ foreach (KeyValuePair kvp in EndpointMaps)
+ {
+ int range = kvp.Key;
+ QuantizationMap map = kvp.Value;
+ int[] table = new int[range + 1];
+ for (int i = 0; i <= range; i++)
+ {
+ table[i] = map.Unquantize(i);
+ }
+
+ tables[range] = table;
+ }
+
+ return tables;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/QuantizationMap.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/QuantizationMap.cs
new file mode 100644
index 00000000..37db8d31
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/QuantizationMap.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+
+internal class QuantizationMap
+{
+ // Flat arrays for O(1) lookup on the hot path (set by Freeze)
+ private int[] quantizationMap = [];
+ private int[] unquantizationMap = [];
+
+ protected List QuantizationMapBuilder { get; set; } = [];
+
+ protected List UnquantizationMapBuilder { get; set; } = [];
+
+ public int Quantize(int x)
+ => (uint)x < (uint)this.quantizationMap.Length
+ ? this.quantizationMap[x]
+ : 0;
+
+ public int Unquantize(int x)
+ => (uint)x < (uint)this.unquantizationMap.Length
+ ? this.unquantizationMap[x]
+ : 0;
+
+ internal static int Log2Floor(int value)
+ {
+ int result = 0;
+ while ((1 << (result + 1)) <= value)
+ {
+ result++;
+ }
+
+ return result;
+ }
+
+ ///
+ /// Converts builder lists to flat arrays. Called after construction is complete.
+ ///
+ protected void Freeze()
+ {
+ this.unquantizationMap = [.. this.UnquantizationMapBuilder];
+ this.quantizationMap = [.. this.QuantizationMapBuilder];
+ this.UnquantizationMapBuilder = [];
+ this.QuantizationMapBuilder = [];
+ }
+
+ protected void GenerateQuantizationMap()
+ {
+ if (this.UnquantizationMapBuilder.Count <= 1)
+ {
+ return;
+ }
+
+ this.QuantizationMapBuilder.Clear();
+ for (int i = 0; i < 256; ++i)
+ {
+ int bestIndex = 0;
+ int bestScore = int.MaxValue;
+ for (int index = 0; index < this.UnquantizationMapBuilder.Count; ++index)
+ {
+ int diff = i - this.UnquantizationMapBuilder[index];
+ int score = diff * diff;
+ if (score < bestScore)
+ {
+ bestIndex = index;
+ bestScore = score;
+ }
+ }
+
+ this.QuantizationMapBuilder.Add(bestIndex);
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/QuintQuantizationMap.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/QuintQuantizationMap.cs
new file mode 100644
index 00000000..5d4a49da
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/QuintQuantizationMap.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+
+internal sealed class QuintQuantizationMap : QuantizationMap
+{
+ public QuintQuantizationMap(int range, Func unquantFunc)
+ {
+ ArgumentOutOfRangeException.ThrowIfNotEqual((range + 1) % 5, 0);
+
+ int bitsPowerOfTwo = (range + 1) / 5;
+ int bitCount = bitsPowerOfTwo == 0 ? 0 : Log2Floor(bitsPowerOfTwo);
+
+ for (int quint = 0; quint < 5; ++quint)
+ {
+ for (int bits = 0; bits < (1 << bitCount); ++bits)
+ {
+ this.UnquantizationMapBuilder.Add(unquantFunc(quint, bits, range));
+ }
+ }
+
+ this.GenerateQuantizationMap();
+ this.Freeze();
+ }
+
+ internal static int GetUnquantizedValue(int quint, int bits, int range)
+ {
+ int a = (bits & 1) != 0 ? 0x1FF : 0;
+ (int b, int c) = range switch
+ {
+ 9 => (0, 113),
+ 19 => ((bits >> 1) & 0x1) is var x ? ((x << 2) | (x << 3) | (x << 8), 54) : default,
+ 39 => ((bits >> 1) & 0x3) is var x ? ((x >> 1) | (x << 1) | (x << 7), 26) : default,
+ 79 => ((bits >> 1) & 0x7) is var x ? ((x >> 1) | (x << 6), 13) : default,
+ 159 => ((bits >> 1) & 0xF) is var x ? ((x >> 3) | (x << 5), 6) : default,
+ _ => throw new ArgumentException("Illegal quint encoding")
+ };
+ int t = (quint * c) + b;
+ t ^= a;
+ t = (a & 0x80) | (t >> 2);
+ return t;
+ }
+
+ internal static int GetUnquantizedWeight(int quint, int bits, int range)
+ {
+ if (range == 4)
+ {
+ int[] weights = [0, 16, 32, 47, 63];
+ return weights[quint];
+ }
+
+ int a = (bits & 1) != 0 ? 0x7F : 0;
+ (int b, int c) = range switch
+ {
+ 9 => (0, 28),
+ 19 => ((bits >> 1) & 0x1) is var x ? ((x << 1) | (x << 6), 13) : default,
+ _ => throw new ArgumentException("Illegal quint encoding")
+ };
+ int t = (quint * c) + b;
+ t ^= a;
+ t = (a & 0x20) | (t >> 2);
+ return t;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/TritQuantizationMap.cs b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/TritQuantizationMap.cs
new file mode 100644
index 00000000..27bb6ac4
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BiseEncoding/Quantize/TritQuantizationMap.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+
+internal sealed class TritQuantizationMap : QuantizationMap
+{
+ public TritQuantizationMap(int range, Func unquantFunc)
+ {
+ ArgumentOutOfRangeException.ThrowIfNotEqual((range + 1) % 3, 0);
+
+ int bitsPowerOfTwo = (range + 1) / 3;
+ int bitCount = bitsPowerOfTwo == 0 ? 0 : Log2Floor(bitsPowerOfTwo);
+
+ for (int trit = 0; trit < 3; ++trit)
+ {
+ for (int bits = 0; bits < (1 << bitCount); ++bits)
+ {
+ this.UnquantizationMapBuilder.Add(unquantFunc(trit, bits, range));
+ }
+ }
+
+ this.GenerateQuantizationMap();
+ this.Freeze();
+ }
+
+ internal static int GetUnquantizedValue(int trit, int bits, int range)
+ {
+ int a = (bits & 1) != 0 ? 0x1FF : 0;
+ (int b, int c) = range switch
+ {
+ 5 => (0, 204),
+ 11 => ((bits >> 1) & 0x1) is var x ? ((x << 1) | (x << 2) | (x << 4) | (x << 8), 93) : default,
+ 23 => ((bits >> 1) & 0x3) is var x ? (x | (x << 2) | (x << 7), 44) : default,
+ 47 => ((bits >> 1) & 0x7) is var x ? (x | (x << 6), 22) : default,
+ 95 => ((bits >> 1) & 0xF) is var x ? ((x >> 2) | (x << 5), 11) : default,
+ 191 => ((bits >> 1) & 0x1F) is var x ? ((x >> 4) | (x << 4), 5) : default,
+ _ => throw new ArgumentException("Illegal trit encoding")
+ };
+ int t = (trit * c) + b;
+ t ^= a;
+ t = (a & 0x80) | (t >> 2);
+ return t;
+ }
+
+ internal static int GetUnquantizedWeight(int trit, int bits, int range)
+ {
+ if (range == 2)
+ {
+ return trit switch
+ {
+ 0 => 0,
+ 1 => 32,
+ _ => 63
+ };
+ }
+
+ int a = (bits & 1) != 0 ? 0x7F : 0;
+ (int b, int c) = range switch
+ {
+ 5 => (0, 50),
+ 11 => ((bits >> 1) & 1) is var x
+ ? (x | (x << 2) | (x << 6), 23)
+ : default,
+ 23 => ((bits >> 1) & 0x3) is var x
+ ? (x | (x << 5), 11)
+ : default,
+ _ => throw new ArgumentException("Illegal trit encoding")
+ };
+ int t = (trit * c) + b;
+ t ^= a;
+ return (a & 0x20) | (t >> 2);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedBlockDecoder.cs b/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedBlockDecoder.cs
new file mode 100644
index 00000000..3e24f058
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedBlockDecoder.cs
@@ -0,0 +1,183 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+using SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BlockDecoder;
+
+///
+/// Shared decode core for the fused (zero-allocation) ASTC block decode pipeline.
+/// Contains BISE extraction and weight infill used by both LDR and HDR decoders.
+///
+internal static class FusedBlockDecoder
+{
+ ///
+ /// Shared decode core: BISE decode, unquantize, and infill.
+ /// Populates and returns the decoded endpoint pair.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ internal static ColorEndpointPair DecodeFusedCore(
+ UInt128 bits, in BlockInfo info, Footprint footprint, Span texelWeights)
+ {
+ // 1. BISE decode color endpoint values
+ int colorCount = info.EndpointMode0.GetColorValuesCount();
+ Span colors = stackalloc int[colorCount];
+ DecodeBiseValues(bits, info.ColorStartBit, info.ColorBitCount, info.ColorValuesRange, colorCount, colors);
+
+ // 2. Batch unquantize color values, then decode endpoint pair
+ Quantization.UnquantizeCEValuesBatch(colors, colorCount, info.ColorValuesRange);
+ ColorEndpointPair endpointPair = EndpointCodec.DecodeColorsForModePolymorphicUnquantized(colors, info.EndpointMode0);
+
+ // 3. BISE decode weights
+ int gridSize = info.GridWidth * info.GridHeight;
+ Span gridWeights = stackalloc int[gridSize];
+ DecodeBiseWeights(bits, info.WeightBitCount, info.WeightRange, gridSize, gridWeights);
+
+ // 4. Batch unquantize weights
+ Quantization.UnquantizeWeightsBatch(gridWeights, gridSize, info.WeightRange);
+
+ // 5. Infill weights from grid to texels (or pass through if identity mapping)
+ if (info.GridWidth == footprint.Width && info.GridHeight == footprint.Height)
+ {
+ gridWeights[..footprint.PixelCount].CopyTo(texelWeights);
+ }
+ else
+ {
+ DecimationInfo decimationInfo = DecimationTable.Get(footprint, info.GridWidth, info.GridHeight);
+ DecimationTable.InfillWeights(gridWeights, decimationInfo, texelWeights);
+ }
+
+ return endpointPair;
+ }
+
+ ///
+ /// Decodes BISE-encoded values from the specified bit region of the block.
+ /// For bit-only encoding with small total bit count, extracts directly from ulong
+ /// without creating a BitStream (avoids per-value ShiftBuffer overhead).
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static void DecodeBiseValues(UInt128 bits, int startBit, int bitCount, int range, int valuesCount, Span result)
+ {
+ (BiseEncodingMode encMode, int bitsPerValue) = BoundedIntegerSequenceCodec.GetPackingModeBitCount(range);
+
+ if (encMode == BiseEncodingMode.BitEncoding)
+ {
+ // Fast path: extract N-bit values directly via shifts
+ int totalBits = valuesCount * bitsPerValue;
+ ulong mask = (1UL << bitsPerValue) - 1;
+
+ if (startBit + totalBits <= 64)
+ {
+ // All color data fits in the low 64 bits
+ ulong data = bits.Low() >> startBit;
+ for (int i = 0; i < valuesCount; i++)
+ {
+ result[i] = (int)(data & mask);
+ data >>= bitsPerValue;
+ }
+ }
+ else
+ {
+ // Spans both halves — use UInt128 shift then extract from low
+ UInt128 shifted = (bits >> startBit) & UInt128Extensions.OnesMask(totalBits);
+ ulong lowBits = shifted.Low();
+ ulong highBits = shifted.High();
+ int bitPos = 0;
+ for (int i = 0; i < valuesCount; i++)
+ {
+ if (bitPos < 64)
+ {
+ ulong val = (lowBits >> bitPos) & mask;
+ if (bitPos + bitsPerValue > 64)
+ {
+ val |= (highBits << (64 - bitPos)) & mask;
+ }
+
+ result[i] = (int)val;
+ }
+ else
+ {
+ result[i] = (int)((highBits >> (bitPos - 64)) & mask);
+ }
+
+ bitPos += bitsPerValue;
+ }
+ }
+
+ return;
+ }
+
+ // Trit/quint encoding: fall back to full BISE decoder
+ UInt128 colorBitMask = UInt128Extensions.OnesMask(bitCount);
+ UInt128 colorBits = (bits >> startBit) & colorBitMask;
+ BitStream colorBitStream = new(colorBits, 128);
+ BoundedIntegerSequenceDecoder decoder = BoundedIntegerSequenceDecoder.GetCached(range);
+ decoder.Decode(valuesCount, ref colorBitStream, result);
+ }
+
+ ///
+ /// Decodes BISE-encoded weight values from the reversed high-end of the block.
+ /// For bit-only encoding, extracts directly from the reversed bits without BitStream.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static void DecodeBiseWeights(UInt128 bits, int weightBitCount, int weightRange, int count, Span result)
+ {
+ (BiseEncodingMode encMode, int bitsPerValue) = BoundedIntegerSequenceCodec.GetPackingModeBitCount(weightRange);
+ UInt128 weightBits = UInt128Extensions.ReverseBits(bits) & UInt128Extensions.OnesMask(weightBitCount);
+
+ if (encMode == BiseEncodingMode.BitEncoding)
+ {
+ // Fast path: extract N-bit values directly via shifts
+ int totalBits = count * bitsPerValue;
+ ulong mask = (1UL << bitsPerValue) - 1;
+
+ if (totalBits <= 64)
+ {
+ ulong data = weightBits.Low();
+ for (int i = 0; i < count; i++)
+ {
+ result[i] = (int)(data & mask);
+ data >>= bitsPerValue;
+ }
+ }
+ else
+ {
+ ulong lowBits = weightBits.Low();
+ ulong highBits = weightBits.High();
+ int bitPos = 0;
+ for (int i = 0; i < count; i++)
+ {
+ if (bitPos < 64)
+ {
+ ulong val = (lowBits >> bitPos) & mask;
+ if (bitPos + bitsPerValue > 64)
+ {
+ val |= (highBits << (64 - bitPos)) & mask;
+ }
+
+ result[i] = (int)val;
+ }
+ else
+ {
+ result[i] = (int)((highBits >> (bitPos - 64)) & mask);
+ }
+
+ bitPos += bitsPerValue;
+ }
+ }
+
+ return;
+ }
+
+ // Trit/quint encoding: fall back to full BISE decoder
+ BitStream weightBitStream = new(weightBits, 128);
+ BoundedIntegerSequenceDecoder decoder = BoundedIntegerSequenceDecoder.GetCached(weightRange);
+ decoder.Decode(count, ref weightBitStream, result);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedHdrBlockDecoder.cs b/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedHdrBlockDecoder.cs
new file mode 100644
index 00000000..f8dc7cf5
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedHdrBlockDecoder.cs
@@ -0,0 +1,223 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BlockDecoder;
+
+///
+/// HDR pixel writers and entry points for the fused decode pipeline.
+/// All methods handle single-partition, non-dual-plane blocks.
+///
+internal static class FusedHdrBlockDecoder
+{
+ ///
+ /// Fused HDR decode to contiguous float buffer.
+ /// Handles single-partition, non-dual-plane blocks with both LDR and HDR endpoints.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ internal static void DecompressBlockFusedHdr(UInt128 bits, in BlockInfo info, Footprint footprint, Span buffer)
+ {
+ Span texelWeights = stackalloc int[footprint.PixelCount];
+ ColorEndpointPair endpointPair = FusedBlockDecoder.DecodeFusedCore(bits, in info, footprint, texelWeights);
+ WriteHdrOutputPixels(buffer, footprint.PixelCount, in endpointPair, texelWeights);
+ }
+
+ ///
+ /// Fused HDR decode writing directly to image buffer at strided positions.
+ /// Handles single-partition, non-dual-plane blocks with both LDR and HDR endpoints.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ internal static void DecompressBlockFusedHdrToImage(
+ UInt128 bits,
+ in BlockInfo info,
+ Footprint footprint,
+ int dstBaseX,
+ int dstBaseY,
+ int imageWidth,
+ Span imageBuffer)
+ {
+ Span texelWeights = stackalloc int[footprint.PixelCount];
+ ColorEndpointPair endpointPair = FusedBlockDecoder.DecodeFusedCore(bits, in info, footprint, texelWeights);
+ WriteHdrOutputPixelsToImage(imageBuffer, footprint, dstBaseX, dstBaseY, imageWidth, in endpointPair, texelWeights);
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteHdrOutputPixels(
+ Span buffer, int pixelCount, in ColorEndpointPair endpointPair, Span texelWeights)
+ {
+ if (endpointPair.IsHdr)
+ {
+ WriteHdrPixels(buffer, pixelCount, in endpointPair, texelWeights);
+ }
+ else
+ {
+ WriteLdrAsHdrPixels(buffer, pixelCount, in endpointPair, texelWeights);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteHdrOutputPixelsToImage(
+ Span imageBuffer,
+ Footprint footprint,
+ int dstBaseX,
+ int dstBaseY,
+ int imageWidth,
+ in ColorEndpointPair endpointPair,
+ Span texelWeights)
+ {
+ if (endpointPair.IsHdr)
+ {
+ WriteHdrPixelsToImage(imageBuffer, footprint, dstBaseX, dstBaseY, imageWidth, in endpointPair, texelWeights);
+ }
+ else
+ {
+ WriteLdrAsHdrPixelsToImage(imageBuffer, footprint, dstBaseX, dstBaseY, imageWidth, in endpointPair, texelWeights);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteLdrAsHdrPixels(Span buffer, int pixelCount, in ColorEndpointPair endpointPair, Span texelWeights)
+ {
+ int lowR = endpointPair.LdrLow.R, lowG = endpointPair.LdrLow.G, lowB = endpointPair.LdrLow.B, lowA = endpointPair.LdrLow.A;
+ int highR = endpointPair.LdrHigh.R, highG = endpointPair.LdrHigh.G, highB = endpointPair.LdrHigh.B, highA = endpointPair.LdrHigh.A;
+
+ for (int i = 0; i < pixelCount; i++)
+ {
+ int weight = texelWeights[i];
+ int offset = i * 4;
+ buffer[offset + 0] = InterpolateLdrAsFloat(lowR, highR, weight);
+ buffer[offset + 1] = InterpolateLdrAsFloat(lowG, highG, weight);
+ buffer[offset + 2] = InterpolateLdrAsFloat(lowB, highB, weight);
+ buffer[offset + 3] = InterpolateLdrAsFloat(lowA, highA, weight);
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteLdrAsHdrPixelsToImage(
+ Span imageBuffer,
+ Footprint footprint,
+ int dstBaseX,
+ int dstBaseY,
+ int imageWidth,
+ in ColorEndpointPair endpointPair,
+ Span texelWeights)
+ {
+ int lowR = endpointPair.LdrLow.R, lowG = endpointPair.LdrLow.G, lowB = endpointPair.LdrLow.B, lowA = endpointPair.LdrLow.A;
+ int highR = endpointPair.LdrHigh.R, highG = endpointPair.LdrHigh.G, highB = endpointPair.LdrHigh.B, highA = endpointPair.LdrHigh.A;
+
+ const int channelsPerPixel = 4;
+ int footprintWidth = footprint.Width;
+ int footprintHeight = footprint.Height;
+ int rowStride = imageWidth * channelsPerPixel;
+
+ for (int pixelY = 0; pixelY < footprintHeight; pixelY++)
+ {
+ int dstRowOffset = ((dstBaseY + pixelY) * rowStride) + (dstBaseX * channelsPerPixel);
+ int srcRowBase = pixelY * footprintWidth;
+
+ for (int pixelX = 0; pixelX < footprintWidth; pixelX++)
+ {
+ int weight = texelWeights[srcRowBase + pixelX];
+ int dstOffset = dstRowOffset + (pixelX * channelsPerPixel);
+ imageBuffer[dstOffset + 0] = InterpolateLdrAsFloat(lowR, highR, weight);
+ imageBuffer[dstOffset + 1] = InterpolateLdrAsFloat(lowG, highG, weight);
+ imageBuffer[dstOffset + 2] = InterpolateLdrAsFloat(lowB, highB, weight);
+ imageBuffer[dstOffset + 3] = InterpolateLdrAsFloat(lowA, highA, weight);
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteHdrPixels(Span buffer, int pixelCount, in ColorEndpointPair endpointPair, Span texelWeights)
+ {
+ bool alphaIsLdr = endpointPair.AlphaIsLdr;
+ int lowR = endpointPair.HdrLow.R, lowG = endpointPair.HdrLow.G, lowB = endpointPair.HdrLow.B, lowA = endpointPair.HdrLow.A;
+ int highR = endpointPair.HdrHigh.R, highG = endpointPair.HdrHigh.G, highB = endpointPair.HdrHigh.B, highA = endpointPair.HdrHigh.A;
+
+ for (int i = 0; i < pixelCount; i++)
+ {
+ int weight = texelWeights[i];
+ int offset = i * 4;
+ buffer[offset + 0] = InterpolateHdrAsFloat(lowR, highR, weight);
+ buffer[offset + 1] = InterpolateHdrAsFloat(lowG, highG, weight);
+ buffer[offset + 2] = InterpolateHdrAsFloat(lowB, highB, weight);
+
+ if (alphaIsLdr)
+ {
+ int interpolated = ((lowA * (64 - weight)) + (highA * weight) + 32) / 64;
+ buffer[offset + 3] = (ushort)Math.Clamp(interpolated, 0, 0xFFFF) / 65535.0f;
+ }
+ else
+ {
+ buffer[offset + 3] = InterpolateHdrAsFloat(lowA, highA, weight);
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteHdrPixelsToImage(
+ Span imageBuffer,
+ Footprint footprint,
+ int dstBaseX,
+ int dstBaseY,
+ int imageWidth,
+ in ColorEndpointPair endpointPair,
+ Span texelWeights)
+ {
+ bool alphaIsLdr = endpointPair.AlphaIsLdr;
+ int lowR = endpointPair.HdrLow.R, lowG = endpointPair.HdrLow.G, lowB = endpointPair.HdrLow.B, lowA = endpointPair.HdrLow.A;
+ int highR = endpointPair.HdrHigh.R, highG = endpointPair.HdrHigh.G, highB = endpointPair.HdrHigh.B, highA = endpointPair.HdrHigh.A;
+
+ const int channelsPerPixel = 4;
+ int footprintWidth = footprint.Width;
+ int footprintHeight = footprint.Height;
+ int rowStride = imageWidth * channelsPerPixel;
+
+ for (int pixelY = 0; pixelY < footprintHeight; pixelY++)
+ {
+ int dstRowOffset = ((dstBaseY + pixelY) * rowStride) + (dstBaseX * channelsPerPixel);
+ int srcRowBase = pixelY * footprintWidth;
+
+ for (int pixelX = 0; pixelX < footprintWidth; pixelX++)
+ {
+ int weight = texelWeights[srcRowBase + pixelX];
+ int dstOffset = dstRowOffset + (pixelX * channelsPerPixel);
+ imageBuffer[dstOffset + 0] = InterpolateHdrAsFloat(lowR, highR, weight);
+ imageBuffer[dstOffset + 1] = InterpolateHdrAsFloat(lowG, highG, weight);
+ imageBuffer[dstOffset + 2] = InterpolateHdrAsFloat(lowB, highB, weight);
+
+ if (alphaIsLdr)
+ {
+ int interpolated = ((lowA * (64 - weight)) + (highA * weight) + 32) / 64;
+ imageBuffer[dstOffset + 3] = (ushort)Math.Clamp(interpolated, 0, 0xFFFF) / 65535.0f;
+ }
+ else
+ {
+ imageBuffer[dstOffset + 3] = InterpolateHdrAsFloat(lowA, highA, weight);
+ }
+ }
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float InterpolateLdrAsFloat(int p0, int p1, int weight)
+ {
+ int c0 = (p0 << 8) | p0;
+ int c1 = (p1 << 8) | p1;
+ int interpolated = ((c0 * (64 - weight)) + (c1 * weight) + 32) / 64;
+ return Math.Clamp(interpolated, 0, 0xFFFF) / 65535.0f;
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static float InterpolateHdrAsFloat(int p0, int p1, int weight)
+ {
+ int interpolated = ((p0 * (64 - weight)) + (p1 * weight) + 32) / 64;
+ ushort clamped = (ushort)Math.Clamp(interpolated, 0, 0xFFFF);
+ ushort halfFloatBits = LogicalBlock.LnsToSf16(clamped);
+ return (float)BitConverter.UInt16BitsToHalf(halfFloatBits);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedLdrBlockDecoder.cs b/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedLdrBlockDecoder.cs
new file mode 100644
index 00000000..5082f152
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/BlockDecoder/FusedLdrBlockDecoder.cs
@@ -0,0 +1,172 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using System.Runtime.Intrinsics;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.BlockDecoder;
+
+///
+/// LDR pixel writers and entry points for the fused decode pipeline.
+/// All methods handle single-partition, non-dual-plane blocks.
+///
+internal static class FusedLdrBlockDecoder
+{
+ private const int BytesPerPixelUnorm8 = 4;
+
+ ///
+ /// Fused LDR decode to contiguous buffer.
+ /// Only handles single-partition, non-dual-plane, LDR blocks.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ internal static void DecompressBlockFusedLdr(UInt128 bits, in BlockInfo info, Footprint footprint, Span buffer)
+ {
+ Span texelWeights = stackalloc int[footprint.PixelCount];
+ ColorEndpointPair endpointPair = FusedBlockDecoder.DecodeFusedCore(bits, in info, footprint, texelWeights);
+ WriteLdrPixels(buffer, footprint.PixelCount, in endpointPair, texelWeights);
+ }
+
+ ///
+ /// Fused LDR decode writing directly to image buffer at strided positions.
+ /// Only handles single-partition, non-dual-plane, LDR blocks.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ internal static void DecompressBlockFusedLdrToImage(
+ UInt128 bits,
+ in BlockInfo info,
+ Footprint footprint,
+ int dstBaseX,
+ int dstBaseY,
+ int imageWidth,
+ Span imageBuffer)
+ {
+ Span texelWeights = stackalloc int[footprint.PixelCount];
+ ColorEndpointPair endpointPair = FusedBlockDecoder.DecodeFusedCore(bits, in info, footprint, texelWeights);
+ WriteLdrPixelsToImage(imageBuffer, footprint, dstBaseX, dstBaseY, imageWidth, in endpointPair, texelWeights);
+ }
+
+ ///
+ /// Writes all pixels for a single-partition LDR block using SIMD where possible.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteLdrPixels(Span buffer, int pixelCount, in ColorEndpointPair endpointPair, Span texelWeights)
+ {
+ int lowR = endpointPair.LdrLow.R, lowG = endpointPair.LdrLow.G, lowB = endpointPair.LdrLow.B, lowA = endpointPair.LdrLow.A;
+ int highR = endpointPair.LdrHigh.R, highG = endpointPair.LdrHigh.G, highB = endpointPair.LdrHigh.B, highA = endpointPair.LdrHigh.A;
+
+ int i = 0;
+ if (Vector128.IsHardwareAccelerated)
+ {
+ int limit = pixelCount - 3;
+ for (; i < limit; i += 4)
+ {
+ Vector128 weights = Vector128.Create(
+ texelWeights[i],
+ texelWeights[i + 1],
+ texelWeights[i + 2],
+ texelWeights[i + 3]);
+ SimdHelpers.Write4PixelLdr(
+ buffer,
+ i * 4,
+ lowR,
+ lowG,
+ lowB,
+ lowA,
+ highR,
+ highG,
+ highB,
+ highA,
+ weights);
+ }
+ }
+
+ for (; i < pixelCount; i++)
+ {
+ SimdHelpers.WriteSinglePixelLdr(
+ buffer,
+ i * 4,
+ lowR,
+ lowG,
+ lowB,
+ lowA,
+ highR,
+ highG,
+ highB,
+ highA,
+ texelWeights[i]);
+ }
+ }
+
+ ///
+ /// Writes LDR pixels directly to image buffer at strided positions.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private static void WriteLdrPixelsToImage(
+ Span imageBuffer,
+ Footprint footprint,
+ int dstBaseX,
+ int dstBaseY,
+ int imageWidth,
+ in ColorEndpointPair endpointPair,
+ Span texelWeights)
+ {
+ int lowR = endpointPair.LdrLow.R, lowG = endpointPair.LdrLow.G, lowB = endpointPair.LdrLow.B, lowA = endpointPair.LdrLow.A;
+ int highR = endpointPair.LdrHigh.R, highG = endpointPair.LdrHigh.G, highB = endpointPair.LdrHigh.B, highA = endpointPair.LdrHigh.A;
+
+ int footprintWidth = footprint.Width;
+ int footprintHeight = footprint.Height;
+ int rowStride = imageWidth * BytesPerPixelUnorm8;
+
+ for (int pixelY = 0; pixelY < footprintHeight; pixelY++)
+ {
+ int dstRowOffset = ((dstBaseY + pixelY) * rowStride) + (dstBaseX * BytesPerPixelUnorm8);
+ int srcRowBase = pixelY * footprintWidth;
+ int pixelX = 0;
+
+ if (Vector128.IsHardwareAccelerated)
+ {
+ int limit = footprintWidth - 3;
+ for (; pixelX < limit; pixelX += 4)
+ {
+ int texelIndex = srcRowBase + pixelX;
+ Vector128 weights = Vector128.Create(
+ texelWeights[texelIndex],
+ texelWeights[texelIndex + 1],
+ texelWeights[texelIndex + 2],
+ texelWeights[texelIndex + 3]);
+ SimdHelpers.Write4PixelLdr(
+ imageBuffer,
+ dstRowOffset + (pixelX * BytesPerPixelUnorm8),
+ lowR,
+ lowG,
+ lowB,
+ lowA,
+ highR,
+ highG,
+ highB,
+ highA,
+ weights);
+ }
+ }
+
+ for (; pixelX < footprintWidth; pixelX++)
+ {
+ SimdHelpers.WriteSinglePixelLdr(
+ imageBuffer,
+ dstRowOffset + (pixelX * BytesPerPixelUnorm8),
+ lowR,
+ lowG,
+ lowB,
+ lowA,
+ highR,
+ highG,
+ highB,
+ highA,
+ texelWeights[srcRowBase + pixelX]);
+ }
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointMode.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointMode.cs
new file mode 100644
index 00000000..d14e50dd
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointMode.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+///
+/// ASTC supports 16 color endpoint encoding schemes, known as endpoint modes
+///
+///
+/// The options for endpoint modes let you vary the following:
+///
+/// - The number of color channels. For example, luminance, luminance+alpha, rgb, or rgba
+/// - The encoding method. For example, direct, base+offset, base+scale, or quantization level
+/// - The data range. For example, low dynamic range or High Dynamic Range
+///
+///
+internal enum ColorEndpointMode
+{
+ LdrLumaDirect = 0,
+ LdrLumaBaseOffset,
+ HdrLumaLargeRange,
+ HdrLumaSmallRange,
+ LdrLumaAlphaDirect,
+ LdrLumaAlphaBaseOffset,
+ LdrRgbBaseScale,
+ HdrRgbBaseScale,
+ LdrRgbDirect,
+ LdrRgbBaseOffset,
+ LdrRgbBaseScaleTwoA,
+ HdrRgbDirect,
+ LdrRgbaDirect,
+ LdrRgbaBaseOffset,
+ HdrRgbDirectLdrAlpha,
+ HdrRgbDirectHdrAlpha,
+
+ // Number of endpoint modes defined by the ASTC specification.
+ ColorEndpointModeCount
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointModeExtensions.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointModeExtensions.cs
new file mode 100644
index 00000000..7ceccf86
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointModeExtensions.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal static class ColorEndpointModeExtensions
+{
+ public static int GetEndpointModeClass(this ColorEndpointMode mode)
+ => (int)mode / 4;
+
+ public static int GetColorValuesCount(this ColorEndpointMode mode)
+ => (mode.GetEndpointModeClass() + 1) * 2;
+
+ ///
+ /// Determines whether the specified endpoint mode uses HDR (High Dynamic Range) encoding.
+ ///
+ ///
+ /// True if the mode is one of the 6 HDR modes (2, 3, 7, 11, 14, 15), false otherwise.
+ ///
+ public static bool IsHdr(this ColorEndpointMode mode)
+ => mode switch
+ {
+ ColorEndpointMode.HdrLumaLargeRange => true, // Mode 2
+ ColorEndpointMode.HdrLumaSmallRange => true, // Mode 3
+ ColorEndpointMode.HdrRgbBaseScale => true, // Mode 7
+ ColorEndpointMode.HdrRgbDirect => true, // Mode 11
+ ColorEndpointMode.HdrRgbDirectLdrAlpha => true, // Mode 14
+ ColorEndpointMode.HdrRgbDirectHdrAlpha => true, // Mode 15
+ _ => false
+ };
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointPair.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointPair.cs
new file mode 100644
index 00000000..b4c11fd5
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/ColorEndpointPair.cs
@@ -0,0 +1,32 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.InteropServices;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+///
+/// A value-type discriminated union representing either an LDR or HDR color endpoint pair.
+///
+[StructLayout(LayoutKind.Auto)]
+internal struct ColorEndpointPair
+{
+ public bool IsHdr;
+
+ // LDR fields (used when IsHdr == false)
+ public Rgba32 LdrLow;
+ public Rgba32 LdrHigh;
+
+ // HDR fields (used when IsHdr == true)
+ public Rgba64 HdrLow;
+ public Rgba64 HdrHigh;
+ public bool AlphaIsLdr;
+ public bool ValuesAreLns;
+
+ public static ColorEndpointPair Ldr(Rgba32 low, Rgba32 high)
+ => new() { IsHdr = false, LdrLow = low, LdrHigh = high };
+
+ public static ColorEndpointPair Hdr(Rgba64 low, Rgba64 high, bool alphaIsLdr = false, bool valuesAreLns = true)
+ => new() { IsHdr = true, HdrLow = low, HdrHigh = high, AlphaIsLdr = alphaIsLdr, ValuesAreLns = valuesAreLns };
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointCodec.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointCodec.cs
new file mode 100644
index 00000000..8bfa15b8
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointCodec.cs
@@ -0,0 +1,251 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using static SixLabors.ImageSharp.Textures.Compression.Astc.Core.Rgba32Extensions;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal static class EndpointCodec
+{
+ ///
+ /// Decodes color endpoints for the specified mode, returning a polymorphic endpoint pair
+ /// that supports both LDR and HDR modes.
+ ///
+ /// Quantized integer values from the ASTC block
+ /// Maximum quantization value
+ /// The color endpoint mode
+ /// A ColorEndpointPair representing either LDR or HDR endpoints
+ public static ColorEndpointPair DecodeColorsForModePolymorphic(ReadOnlySpan values, int maxValue, ColorEndpointMode mode)
+ {
+ if (mode.IsHdr())
+ {
+ (Rgba64 low, Rgba64 high) = HdrEndpointDecoder.DecodeHdrMode(values, maxValue, mode);
+ bool alphaIsLdr = mode == ColorEndpointMode.HdrRgbDirectLdrAlpha;
+ return ColorEndpointPair.Hdr(low, high, alphaIsLdr);
+ }
+ else
+ {
+ (Rgba32 low, Rgba32 high) = DecodeColorsForMode(values, maxValue, mode);
+ return ColorEndpointPair.Ldr(low, high);
+ }
+ }
+
+ public static (Rgba32 EndpointLowRgba, Rgba32 EndpointHighRgba) DecodeColorsForMode(ReadOnlySpan values, int maxValue, ColorEndpointMode mode)
+ {
+ int count = mode.GetColorValuesCount();
+ Span unquantizedValues = stackalloc int[count];
+ int copyLen = Math.Min(count, values.Length);
+ for (int i = 0; i < copyLen; i++)
+ {
+ unquantizedValues[i] = values[i];
+ }
+
+ UnquantizeInline(unquantizedValues, maxValue);
+ ColorEndpointPair pair = DecodeColorsForModeUnquantized(unquantizedValues, mode);
+ return (pair.LdrLow, pair.LdrHigh);
+ }
+
+ ///
+ /// Decodes color endpoints from already-unquantized values, supporting both LDR and HDR modes.
+ /// Called from the fused HDR decode path where BISE decode + batch unquantize
+ /// have already been performed. Returns a ColorEndpointPair (LDR or HDR).
+ ///
+ internal static ColorEndpointPair DecodeColorsForModePolymorphicUnquantized(ReadOnlySpan unquantizedValues, ColorEndpointMode mode)
+ {
+ if (mode.IsHdr())
+ {
+ (Rgba64 low, Rgba64 high) = HdrEndpointDecoder.DecodeHdrModeUnquantized(unquantizedValues, mode);
+ bool alphaIsLdr = mode == ColorEndpointMode.HdrRgbDirectLdrAlpha;
+ return ColorEndpointPair.Hdr(low, high, alphaIsLdr);
+ }
+
+ return DecodeColorsForModeUnquantized(unquantizedValues, mode);
+ }
+
+ ///
+ /// Decodes color endpoints from already-unquantized values.
+ /// Called from the fused decode path where BISE decode + batch unquantize
+ /// have already been performed. Returns an LDR ColorEndpointPair.
+ ///
+ internal static ColorEndpointPair DecodeColorsForModeUnquantized(ReadOnlySpan unquantizedValues, ColorEndpointMode mode)
+ {
+ Rgba32 endpointLowRgba, endpointHighRgba;
+
+ switch (mode)
+ {
+ case ColorEndpointMode.LdrLumaDirect:
+ endpointLowRgba = ClampedRgba32(unquantizedValues[0], unquantizedValues[0], unquantizedValues[0]);
+ endpointHighRgba = ClampedRgba32(unquantizedValues[1], unquantizedValues[1], unquantizedValues[1]);
+ break;
+ case ColorEndpointMode.LdrLumaBaseOffset:
+ {
+ int l0 = (unquantizedValues[0] >> 2) | (unquantizedValues[1] & 0xC0);
+ int l1 = Math.Min(l0 + (unquantizedValues[1] & 0x3F), 0xFF);
+ endpointLowRgba = ClampedRgba32(l0, l0, l0);
+ endpointHighRgba = ClampedRgba32(l1, l1, l1);
+ break;
+ }
+
+ case ColorEndpointMode.LdrLumaAlphaDirect:
+ endpointLowRgba = ClampedRgba32(unquantizedValues[0], unquantizedValues[0], unquantizedValues[0], unquantizedValues[2]);
+ endpointHighRgba = ClampedRgba32(unquantizedValues[1], unquantizedValues[1], unquantizedValues[1], unquantizedValues[3]);
+ break;
+ case ColorEndpointMode.LdrLumaAlphaBaseOffset:
+ {
+ (int b0, int a0) = BitOperations.TransferPrecision(unquantizedValues[1], unquantizedValues[0]);
+ (int b2, int a2) = BitOperations.TransferPrecision(unquantizedValues[3], unquantizedValues[2]);
+ endpointLowRgba = ClampedRgba32(a0, a0, a0, a2);
+ int highLuma = a0 + b0;
+ endpointHighRgba = ClampedRgba32(highLuma, highLuma, highLuma, a2 + b2);
+ break;
+ }
+
+ case ColorEndpointMode.LdrRgbBaseScale:
+ endpointLowRgba = ClampedRgba32(
+ (unquantizedValues[0] * unquantizedValues[3]) >> 8,
+ (unquantizedValues[1] * unquantizedValues[3]) >> 8,
+ (unquantizedValues[2] * unquantizedValues[3]) >> 8);
+ endpointHighRgba = ClampedRgba32(unquantizedValues[0], unquantizedValues[1], unquantizedValues[2]);
+ break;
+ case ColorEndpointMode.LdrRgbDirect:
+ {
+ int sum0 = unquantizedValues[0] + unquantizedValues[2] + unquantizedValues[4];
+ int sum1 = unquantizedValues[1] + unquantizedValues[3] + unquantizedValues[5];
+ if (sum1 < sum0)
+ {
+ endpointLowRgba = ClampedRgba32(
+ r: (unquantizedValues[1] + unquantizedValues[5]) >> 1,
+ g: (unquantizedValues[3] + unquantizedValues[5]) >> 1,
+ b: unquantizedValues[5]);
+ endpointHighRgba = ClampedRgba32(
+ r: (unquantizedValues[0] + unquantizedValues[4]) >> 1,
+ g: (unquantizedValues[2] + unquantizedValues[4]) >> 1,
+ b: unquantizedValues[4]);
+ }
+ else
+ {
+ endpointLowRgba = ClampedRgba32(unquantizedValues[0], unquantizedValues[2], unquantizedValues[4]);
+ endpointHighRgba = ClampedRgba32(unquantizedValues[1], unquantizedValues[3], unquantizedValues[5]);
+ }
+
+ break;
+ }
+
+ case ColorEndpointMode.LdrRgbBaseOffset:
+ {
+ (int b0, int a0) = BitOperations.TransferPrecision(unquantizedValues[1], unquantizedValues[0]);
+ (int b1, int a1) = BitOperations.TransferPrecision(unquantizedValues[3], unquantizedValues[2]);
+ (int b2, int a2) = BitOperations.TransferPrecision(unquantizedValues[5], unquantizedValues[4]);
+ if (b0 + b1 + b2 < 0)
+ {
+ endpointLowRgba = ClampedRgba32(
+ r: (a0 + b0 + a2 + b2) >> 1,
+ g: (a1 + b1 + a2 + b2) >> 1,
+ b: a2 + b2);
+ endpointHighRgba = ClampedRgba32(
+ r: (a0 + a2) >> 1,
+ g: (a1 + a2) >> 1,
+ b: a2);
+ }
+ else
+ {
+ endpointLowRgba = ClampedRgba32(a0, a1, a2);
+ endpointHighRgba = ClampedRgba32(a0 + b0, a1 + b1, a2 + b2);
+ }
+
+ break;
+ }
+
+ case ColorEndpointMode.LdrRgbBaseScaleTwoA:
+ endpointLowRgba = ClampedRgba32(
+ r: (unquantizedValues[0] * unquantizedValues[3]) >> 8,
+ g: (unquantizedValues[1] * unquantizedValues[3]) >> 8,
+ b: (unquantizedValues[2] * unquantizedValues[3]) >> 8,
+ a: unquantizedValues[4]);
+ endpointHighRgba = ClampedRgba32(unquantizedValues[0], unquantizedValues[1], unquantizedValues[2], unquantizedValues[5]);
+ break;
+ case ColorEndpointMode.LdrRgbaDirect:
+ {
+ int sum0 = unquantizedValues[0] + unquantizedValues[2] + unquantizedValues[4];
+ int sum1 = unquantizedValues[1] + unquantizedValues[3] + unquantizedValues[5];
+ if (sum1 >= sum0)
+ {
+ endpointLowRgba = ClampedRgba32(unquantizedValues[0], unquantizedValues[2], unquantizedValues[4], unquantizedValues[6]);
+ endpointHighRgba = ClampedRgba32(unquantizedValues[1], unquantizedValues[3], unquantizedValues[5], unquantizedValues[7]);
+ }
+ else
+ {
+ endpointLowRgba = ClampedRgba32(
+ r: (unquantizedValues[1] + unquantizedValues[5]) >> 1,
+ g: (unquantizedValues[3] + unquantizedValues[5]) >> 1,
+ b: unquantizedValues[5],
+ a: unquantizedValues[7]);
+ endpointHighRgba = ClampedRgba32(
+ r: (unquantizedValues[0] + unquantizedValues[4]) >> 1,
+ g: (unquantizedValues[2] + unquantizedValues[4]) >> 1,
+ b: unquantizedValues[4],
+ a: unquantizedValues[6]);
+ }
+
+ break;
+ }
+
+ case ColorEndpointMode.LdrRgbaBaseOffset:
+ {
+ (int b0, int a0) = BitOperations.TransferPrecision(unquantizedValues[1], unquantizedValues[0]);
+ (int b1, int a1) = BitOperations.TransferPrecision(unquantizedValues[3], unquantizedValues[2]);
+ (int b2, int a2) = BitOperations.TransferPrecision(unquantizedValues[5], unquantizedValues[4]);
+ (int b3, int a3) = BitOperations.TransferPrecision(unquantizedValues[7], unquantizedValues[6]);
+ if (b0 + b1 + b2 < 0)
+ {
+ endpointLowRgba = ClampedRgba32(
+ r: (a0 + b0 + a2 + b2) >> 1,
+ g: (a1 + b1 + a2 + b2) >> 1,
+ b: a2 + b2,
+ a: a3 + b3);
+ endpointHighRgba = ClampedRgba32(
+ r: (a0 + a2) >> 1,
+ g: (a1 + a2) >> 1,
+ b: a2,
+ a: a3);
+ }
+ else
+ {
+ endpointLowRgba = ClampedRgba32(a0, a1, a2, a3);
+ endpointHighRgba = ClampedRgba32(a0 + b0, a1 + b1, a2 + b2, a3 + b3);
+ }
+
+ break;
+ }
+
+ default:
+ endpointLowRgba = default;
+ endpointHighRgba = default;
+ break;
+ }
+
+ return ColorEndpointPair.Ldr(endpointLowRgba, endpointHighRgba);
+ }
+
+ internal static int[] UnquantizeArray(int[] values, int maxValue)
+ {
+ int[] result = new int[values.Length];
+ for (int i = 0; i < values.Length; ++i)
+ {
+ result[i] = Quantization.UnquantizeCEValueFromRange(values[i], maxValue);
+ }
+
+ return result;
+ }
+
+ private static void UnquantizeInline(Span values, int maxValue)
+ {
+ for (int i = 0; i < values.Length; ++i)
+ {
+ values[i] = Quantization.UnquantizeCEValueFromRange(values[i], maxValue);
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncoder.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncoder.cs
new file mode 100644
index 00000000..9cbe8c1f
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncoder.cs
@@ -0,0 +1,551 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using static SixLabors.ImageSharp.Textures.Compression.Astc.Core.Rgba32Extensions;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal static class EndpointEncoder
+{
+ public static bool UsesBlueContract(int maxValue, ColorEndpointMode mode, List values)
+ {
+ int valueCount = mode.GetColorValuesCount();
+ ArgumentOutOfRangeException.ThrowIfLessThan(values.Count, valueCount);
+
+ switch (mode)
+ {
+ case ColorEndpointMode.LdrRgbDirect:
+ case ColorEndpointMode.LdrRgbaDirect:
+ {
+ int maxValueCount = Math.Max(ColorEndpointMode.LdrRgbDirect.GetColorValuesCount(), ColorEndpointMode.LdrRgbaDirect.GetColorValuesCount());
+ int[] v = new int[maxValueCount];
+ for (int i = 0; i < maxValueCount; ++i)
+ {
+ v[i] = i < values.Count ? values[i] : 0;
+ }
+
+ int[] unquantizedValues = EndpointCodec.UnquantizeArray(v, maxValue);
+ int s0 = unquantizedValues[0] + unquantizedValues[2] + unquantizedValues[4];
+ int s1 = unquantizedValues[1] + unquantizedValues[3] + unquantizedValues[5];
+ return s0 > s1;
+ }
+
+ case ColorEndpointMode.LdrRgbBaseOffset:
+ case ColorEndpointMode.LdrRgbaBaseOffset:
+ {
+ int maxValueCount = Math.Max(ColorEndpointMode.LdrRgbBaseOffset.GetColorValuesCount(), ColorEndpointMode.LdrRgbaBaseOffset.GetColorValuesCount());
+ int[] v = new int[maxValueCount];
+ for (int i = 0; i < maxValueCount; ++i)
+ {
+ v[i] = i < values.Count ? values[i] : 0;
+ }
+
+ int[] unquantizedValues = EndpointCodec.UnquantizeArray(v, maxValue);
+ (int b0, int a0) = BitOperations.TransferPrecision(unquantizedValues[1], unquantizedValues[0]);
+ (int b1, int a1) = BitOperations.TransferPrecision(unquantizedValues[3], unquantizedValues[2]);
+ (int b2, int a2) = BitOperations.TransferPrecision(unquantizedValues[5], unquantizedValues[4]);
+ return (b0 + b1 + b2) < 0;
+ }
+
+ default:
+ return false;
+ }
+ }
+
+ // TODO: Extract an interface and implement instances for each encoding mode
+ public static bool EncodeColorsForMode(Rgba32 endpointLowRgba, Rgba32 endpointHighRgba, int maxValue, EndpointEncodingMode encodingMode, out ColorEndpointMode astcMode, List values)
+ {
+ bool needsWeightSwap = false;
+ astcMode = ColorEndpointMode.LdrLumaDirect;
+ int valueCount = encodingMode.GetValuesCount();
+ for (int i = values.Count; i < valueCount; ++i)
+ {
+ values.Add(0);
+ }
+
+ switch (encodingMode)
+ {
+ case EndpointEncodingMode.DirectLuma:
+ return EncodeColorsLuma(endpointLowRgba, endpointHighRgba, maxValue, out astcMode, values);
+ case EndpointEncodingMode.DirectLumaAlpha:
+ {
+ int avg1 = endpointLowRgba.GetAverage();
+ int avg2 = endpointHighRgba.GetAverage();
+ values[0] = Quantization.QuantizeCEValueToRange(avg1, maxValue);
+ values[1] = Quantization.QuantizeCEValueToRange(avg2, maxValue);
+ values[2] = Quantization.QuantizeCEValueToRange(endpointLowRgba.GetChannel(3), maxValue);
+ values[3] = Quantization.QuantizeCEValueToRange(endpointHighRgba.GetChannel(3), maxValue);
+ astcMode = ColorEndpointMode.LdrLumaAlphaDirect;
+ break;
+ }
+
+ case EndpointEncodingMode.BaseScaleRgb:
+ case EndpointEncodingMode.BaseScaleRgba:
+ {
+ Rgba32 baseColor = endpointHighRgba;
+ Rgba32 scaled = endpointLowRgba;
+
+ int numChannelsGe = 0;
+ for (int i = 0; i < 3; ++i)
+ {
+ numChannelsGe += endpointHighRgba.GetChannel(i) >= endpointLowRgba.GetChannel(i) ? 1 : 0;
+ }
+
+ if (numChannelsGe < 2)
+ {
+ needsWeightSwap = true;
+ (scaled, baseColor) = (baseColor, scaled);
+ }
+
+ int[] quantizedBase = QuantizeColorArray(baseColor, maxValue);
+ int[] unquantizedBase = EndpointCodec.UnquantizeArray(quantizedBase, maxValue);
+
+ int numSamples = 0;
+ int scaleSum = 0;
+ for (int i = 0; i < 3; ++i)
+ {
+ int x = unquantizedBase[i];
+ if (x != 0)
+ {
+ ++numSamples;
+ scaleSum += (scaled.GetChannel(i) * 256) / x;
+ }
+ }
+
+ values[0] = quantizedBase[0];
+ values[1] = quantizedBase[1];
+ values[2] = quantizedBase[2];
+ if (numSamples > 0)
+ {
+ int avgScale = Math.Clamp(scaleSum / numSamples, 0, 255);
+ values[3] = Quantization.QuantizeCEValueToRange(avgScale, maxValue);
+ }
+ else
+ {
+ values[3] = maxValue;
+ }
+
+ astcMode = ColorEndpointMode.LdrRgbBaseScale;
+
+ if (encodingMode == EndpointEncodingMode.BaseScaleRgba)
+ {
+ values[4] = Quantization.QuantizeCEValueToRange(scaled.GetChannel(3), maxValue);
+ values[5] = Quantization.QuantizeCEValueToRange(baseColor.GetChannel(3), maxValue);
+ astcMode = ColorEndpointMode.LdrRgbBaseScaleTwoA;
+ }
+
+ break;
+ }
+
+ case EndpointEncodingMode.DirectRgb:
+ case EndpointEncodingMode.DirectRgba:
+ return EncodeColorsRGBA(endpointLowRgba, endpointHighRgba, maxValue, encodingMode == EndpointEncodingMode.DirectRgba, out astcMode, values);
+ default:
+ throw new InvalidOperationException("Unimplemented color encoding.");
+ }
+
+ return needsWeightSwap;
+ }
+
+ private static int[] QuantizeColorArray(Rgba32 c, int maxValue)
+ {
+ int[] array = new int[4];
+ for (int i = 0; i < 4; ++i)
+ {
+ array[i] = Quantization.QuantizeCEValueToRange(c.GetChannel(i), maxValue);
+ }
+
+ return array;
+ }
+
+ private static bool EncodeColorsLuma(Rgba32 endpointLow, Rgba32 endpointHigh, int maxValue, out ColorEndpointMode astcMode, List values)
+ {
+ astcMode = ColorEndpointMode.LdrLumaDirect;
+ ArgumentOutOfRangeException.ThrowIfLessThan(values.Count, 2);
+
+ int avg1 = endpointLow.GetAverage();
+ int avg2 = endpointHigh.GetAverage();
+
+ bool needsWeightSwap = false;
+ if (avg1 > avg2)
+ {
+ needsWeightSwap = true;
+ (avg2, avg1) = (avg1, avg2);
+ }
+
+ int offset = Math.Min(avg2 - avg1, 0x3F);
+ int quantOffLow = Quantization.QuantizeCEValueToRange((avg1 & 0x3F) << 2, maxValue);
+ int quantOffHigh = Quantization.QuantizeCEValueToRange((avg1 & 0xC0) | offset, maxValue);
+
+ int quantLow = Quantization.QuantizeCEValueToRange(avg1, maxValue);
+ int quantHigh = Quantization.QuantizeCEValueToRange(avg2, maxValue);
+
+ values[0] = quantOffLow;
+ values[1] = quantOffHigh;
+ (Rgba32 decLowOff, Rgba32 decHighOff) = EndpointCodec.DecodeColorsForMode(values.ToArray(), maxValue, ColorEndpointMode.LdrLumaBaseOffset);
+
+ values[0] = quantLow;
+ values[1] = quantHigh;
+ (Rgba32 decLowDir, Rgba32 decHighDir) = EndpointCodec.DecodeColorsForMode(values.ToArray(), maxValue, ColorEndpointMode.LdrLumaDirect);
+
+ int calculateErrorOff = 0;
+ int calculateErrorDir = 0;
+ if (needsWeightSwap)
+ {
+ calculateErrorDir = SquaredError(decLowDir, endpointHigh) + SquaredError(decHighDir, endpointLow);
+ calculateErrorOff = SquaredError(decLowOff, endpointHigh) + SquaredError(decHighOff, endpointLow);
+ }
+ else
+ {
+ calculateErrorDir = SquaredError(decLowDir, endpointLow) + SquaredError(decHighDir, endpointHigh);
+ calculateErrorOff = SquaredError(decLowOff, endpointLow) + SquaredError(decHighOff, endpointHigh);
+ }
+
+ if (calculateErrorDir <= calculateErrorOff)
+ {
+ values[0] = quantLow;
+ values[1] = quantHigh;
+ astcMode = ColorEndpointMode.LdrLumaDirect;
+ }
+ else
+ {
+ values[0] = quantOffLow;
+ values[1] = quantOffHigh;
+ astcMode = ColorEndpointMode.LdrLumaBaseOffset;
+ }
+
+ return needsWeightSwap;
+ }
+
+ private static bool EncodeColorsRGBA(Rgba32 endpointLowRgba, Rgba32 endpointHighRgba, int maxValue, bool withAlpha, out ColorEndpointMode astcMode, List values)
+ {
+ astcMode = ColorEndpointMode.LdrRgbDirect;
+ int numChannels = withAlpha ? 4 : 3;
+
+ Rgba32 invertedBlueContractLow = endpointLowRgba.WithInvertedBlueContract();
+ Rgba32 invertedBlueContractHigh = endpointHighRgba.WithInvertedBlueContract();
+
+ int[] directBase = new int[4];
+ int[] directOffset = new int[4];
+ for (int i = 0; i < 4; ++i)
+ {
+ directBase[i] = endpointLowRgba.GetChannel(i);
+ directOffset[i] = Math.Clamp(endpointHighRgba.GetChannel(i) - endpointLowRgba.GetChannel(i), -32, 31);
+ (directOffset[i], directBase[i]) = BitOperations.TransferPrecisionInverse(directOffset[i], directBase[i]);
+ }
+
+ int[] invertedBlueContractBase = new int[4];
+ int[] invertedBlueContractOffset = new int[4];
+ for (int i = 0; i < 4; ++i)
+ {
+ invertedBlueContractBase[i] = invertedBlueContractHigh.GetChannel(i);
+ invertedBlueContractOffset[i] = Math.Clamp(invertedBlueContractLow.GetChannel(i) - invertedBlueContractHigh.GetChannel(i), -32, 31);
+ (invertedBlueContractOffset[i], invertedBlueContractBase[i]) = BitOperations.TransferPrecisionInverse(invertedBlueContractOffset[i], invertedBlueContractBase[i]);
+ }
+
+ int[] directBaseSwapped = new int[4];
+ int[] directOffsetSwapped = new int[4];
+ for (int i = 0; i < 4; ++i)
+ {
+ directBaseSwapped[i] = endpointHighRgba.GetChannel(i);
+ directOffsetSwapped[i] = Math.Clamp(endpointLowRgba.GetChannel(i) - endpointHighRgba.GetChannel(i), -32, 31);
+ (directOffsetSwapped[i], directBaseSwapped[i]) = BitOperations.TransferPrecisionInverse(directOffsetSwapped[i], directBaseSwapped[i]);
+ }
+
+ int[] invertedBlueContractBaseSwapped = new int[4];
+ int[] invertedBlueContractOffsetSwapped = new int[4];
+ for (int i = 0; i < 4; ++i)
+ {
+ invertedBlueContractBaseSwapped[i] = invertedBlueContractLow.GetChannel(i);
+ invertedBlueContractOffsetSwapped[i] = Math.Clamp(invertedBlueContractHigh.GetChannel(i) - invertedBlueContractLow.GetChannel(i), -32, 31);
+ (invertedBlueContractOffsetSwapped[i], invertedBlueContractBaseSwapped[i]) = BitOperations.TransferPrecisionInverse(invertedBlueContractOffsetSwapped[i], invertedBlueContractBaseSwapped[i]);
+ }
+
+ QuantizedEndpointPair directQuantized = new(endpointLowRgba, endpointHighRgba, maxValue);
+ QuantizedEndpointPair bcQuantized = new(invertedBlueContractLow, invertedBlueContractHigh, maxValue);
+
+ QuantizedEndpointPair offsetQuantized = new(ClampedRgba32(directBase[0], directBase[1], directBase[2], directBase[3]), ClampedRgba32(directOffset[0], directOffset[1], directOffset[2], directOffset[3]), maxValue);
+ QuantizedEndpointPair bcOffsetQuantized = new(ClampedRgba32(invertedBlueContractBase[0], invertedBlueContractBase[1], invertedBlueContractBase[2], invertedBlueContractBase[3]), ClampedRgba32(invertedBlueContractOffset[0], invertedBlueContractOffset[1], invertedBlueContractOffset[2], invertedBlueContractOffset[3]), maxValue);
+
+ QuantizedEndpointPair offsetSwappedQuantized = new(ClampedRgba32(directBaseSwapped[0], directBaseSwapped[1], directBaseSwapped[2], directBaseSwapped[3]), ClampedRgba32(directOffsetSwapped[0], directOffsetSwapped[1], directOffsetSwapped[2], directOffsetSwapped[3]), maxValue);
+ QuantizedEndpointPair bcOffsetSwappedQuantized = new(ClampedRgba32(invertedBlueContractBaseSwapped[0], invertedBlueContractBaseSwapped[1], invertedBlueContractBaseSwapped[2], invertedBlueContractBaseSwapped[3]), ClampedRgba32(invertedBlueContractOffsetSwapped[0], invertedBlueContractOffsetSwapped[1], invertedBlueContractOffsetSwapped[2], invertedBlueContractOffsetSwapped[3]), maxValue);
+
+ List errors = new(6);
+
+ // 3.1 regular unquantized error
+ {
+ int[] rgbaLow = directQuantized.UnquantizedLow();
+ int[] rgbaHigh = directQuantized.UnquantizedHigh();
+ Rgba32 lowColor = ClampedRgba32(rgbaLow[0], rgbaLow[1], rgbaLow[2], rgbaLow[3]);
+ Rgba32 highColor = ClampedRgba32(rgbaHigh[0], rgbaHigh[1], rgbaHigh[2], rgbaHigh[3]);
+ int squaredRgbError = withAlpha
+ ? SquaredError(lowColor, endpointLowRgba) + SquaredError(highColor, endpointHighRgba)
+ : SquaredErrorRgb(lowColor, endpointLowRgba) + SquaredErrorRgb(highColor, endpointHighRgba);
+ errors.Add(new CEEncodingOption(squaredRgbError, directQuantized, false, false, false));
+ }
+
+ // 3.2 blue-contract
+ {
+ int[] blueContractUnquantizedLow = bcQuantized.UnquantizedLow();
+ int[] blueContractUnquantizedHigh = bcQuantized.UnquantizedHigh();
+ Rgba32 blueContractLow = RgbaColorExtensions.WithBlueContract(blueContractUnquantizedLow[0], blueContractUnquantizedLow[1], blueContractUnquantizedLow[2], blueContractUnquantizedLow[3]);
+ Rgba32 blueContractHigh = RgbaColorExtensions.WithBlueContract(blueContractUnquantizedHigh[0], blueContractUnquantizedHigh[1], blueContractUnquantizedHigh[2], blueContractUnquantizedHigh[3]);
+
+ // TODO: How to handle alpha for this entire functions??
+ int blueContractSquaredError = withAlpha
+ ? SquaredError(blueContractLow, endpointLowRgba) + SquaredError(blueContractHigh, endpointHighRgba)
+ : SquaredErrorRgb(blueContractLow, endpointLowRgba) + SquaredErrorRgb(blueContractHigh, endpointHighRgba);
+
+ errors.Add(new CEEncodingOption(blueContractSquaredError, bcQuantized, swapEndpoints: false, blueContract: true, useOffsetMode: false));
+ }
+
+ // 3.3 base/offset
+ void ComputeBaseOffsetError(QuantizedEndpointPair pair, bool swapped)
+ {
+ int[] baseArr = pair.UnquantizedLow();
+ int[] offsetArr = pair.UnquantizedHigh();
+
+ Rgba32 baseColor = ClampedRgba32(baseArr[0], baseArr[1], baseArr[2], baseArr[3]);
+ Rgba32 offsetColor = ClampedRgba32(offsetArr[0], offsetArr[1], offsetArr[2], offsetArr[3]).AsOffsetFrom(baseColor);
+
+ int baseOffsetError = 0;
+ if (swapped)
+ {
+ baseOffsetError = withAlpha
+ ? SquaredError(baseColor, endpointHighRgba) + SquaredError(offsetColor, endpointLowRgba)
+ : SquaredErrorRgb(baseColor, endpointHighRgba) + SquaredErrorRgb(offsetColor, endpointLowRgba);
+ }
+ else
+ {
+ baseOffsetError = withAlpha
+ ? SquaredError(baseColor, endpointLowRgba) + SquaredError(offsetColor, endpointHighRgba)
+ : SquaredErrorRgb(baseColor, endpointLowRgba) + SquaredErrorRgb(offsetColor, endpointHighRgba);
+ }
+
+ errors.Add(new CEEncodingOption(baseOffsetError, pair, swapped, false, true));
+ }
+
+ ComputeBaseOffsetError(offsetQuantized, false);
+
+ void ComputeBaseOffsetBlueContractError(QuantizedEndpointPair pair, bool swapped)
+ {
+ int[] baseArr = pair.UnquantizedLow();
+ int[] offsetArr = pair.UnquantizedHigh();
+
+ Rgba32 baseColor = ClampedRgba32(baseArr[0], baseArr[1], baseArr[2], baseArr[3]);
+ Rgba32 offsetColor = ClampedRgba32(offsetArr[0], offsetArr[1], offsetArr[2], offsetArr[3]).AsOffsetFrom(baseColor);
+
+ baseColor = baseColor.WithBlueContract();
+ offsetColor = offsetColor.WithBlueContract();
+
+ int squaredBlueContractError = 0;
+ if (swapped)
+ {
+ squaredBlueContractError = withAlpha
+ ? SquaredError(baseColor, endpointLowRgba) + SquaredError(offsetColor, endpointHighRgba)
+ : SquaredErrorRgb(baseColor, endpointLowRgba) + SquaredErrorRgb(offsetColor, endpointHighRgba);
+ }
+ else
+ {
+ squaredBlueContractError = withAlpha
+ ? SquaredError(baseColor, endpointHighRgba) + SquaredError(offsetColor, endpointLowRgba)
+ : SquaredErrorRgb(baseColor, endpointHighRgba) + SquaredErrorRgb(offsetColor, endpointLowRgba);
+ }
+
+ errors.Add(new CEEncodingOption(squaredBlueContractError, pair, swapped, true, true));
+ }
+
+ ComputeBaseOffsetBlueContractError(bcOffsetQuantized, false);
+ ComputeBaseOffsetError(offsetSwappedQuantized, true);
+ ComputeBaseOffsetBlueContractError(bcOffsetSwappedQuantized, true);
+
+ errors.Sort((a, b) => a.Error().CompareTo(b.Error()));
+
+ foreach (CEEncodingOption measurement in errors)
+ {
+ bool needsWeightSwap = false;
+ if (measurement.Pack(withAlpha, out ColorEndpointMode modeUnused, values, ref needsWeightSwap))
+ {
+ return needsWeightSwap;
+ }
+ }
+
+ throw new InvalidOperationException("Shouldn't have reached this point");
+ }
+
+ private class QuantizedEndpointPair
+ {
+ private readonly Rgba32 originalLow;
+ private readonly Rgba32 originalHigh;
+ private readonly int[] quantizedLow;
+ private readonly int[] quantizedHigh;
+ private readonly int[] unquantizedLow;
+ private readonly int[] unquantizedHigh;
+
+ public QuantizedEndpointPair(Rgba32 low, Rgba32 high, int maxValue)
+ {
+ this.originalLow = low;
+ this.originalHigh = high;
+ this.quantizedLow = QuantizeColorArray(low, maxValue);
+ this.quantizedHigh = QuantizeColorArray(high, maxValue);
+ this.unquantizedLow = EndpointCodec.UnquantizeArray(this.quantizedLow, maxValue);
+ this.unquantizedHigh = EndpointCodec.UnquantizeArray(this.quantizedHigh, maxValue);
+ }
+
+ public int[] QuantizedLow() => this.quantizedLow;
+
+ public int[] QuantizedHigh() => this.quantizedHigh;
+
+ public int[] UnquantizedLow() => this.unquantizedLow;
+
+ public int[] UnquantizedHigh() => this.unquantizedHigh;
+
+ public Rgba32 OriginalLow() => this.originalLow;
+
+ public Rgba32 OriginalHigh() => this.originalHigh;
+ }
+
+ private class CEEncodingOption
+ {
+ private readonly int squaredError;
+ private readonly QuantizedEndpointPair quantizedEndpoints;
+ private readonly bool swapEndpoints;
+ private readonly bool blueContract;
+ private readonly bool useOffsetMode;
+
+ public CEEncodingOption(
+ int squaredError,
+ QuantizedEndpointPair quantizedEndpoints,
+ bool swapEndpoints,
+ bool blueContract,
+ bool useOffsetMode)
+ {
+ this.squaredError = squaredError;
+ this.quantizedEndpoints = quantizedEndpoints;
+ this.swapEndpoints = swapEndpoints;
+ this.blueContract = blueContract;
+ this.useOffsetMode = useOffsetMode;
+ }
+
+ public bool Pack(bool hasAlpha, out ColorEndpointMode endpointMode, List values, ref bool needsWeightSwap)
+ {
+ endpointMode = ColorEndpointMode.LdrLumaDirect;
+ int[] unquantizedLowOriginal = this.quantizedEndpoints.UnquantizedLow();
+ int[] unquantizedHighOriginal = this.quantizedEndpoints.UnquantizedHigh();
+
+ int[] unquantizedLow = (int[])unquantizedLowOriginal.Clone();
+ int[] unquantizedHigh = (int[])unquantizedHighOriginal.Clone();
+
+ if (this.useOffsetMode)
+ {
+ for (int i = 0; i < 4; ++i)
+ {
+ (unquantizedHigh[i], unquantizedLow[i]) = BitOperations.TransferPrecision(unquantizedHigh[i], unquantizedLow[i]);
+ }
+ }
+
+ int sum0 = 0, sum1 = 0;
+ for (int i = 0; i < 3; ++i)
+ {
+ sum0 += unquantizedLow[i];
+ sum1 += unquantizedHigh[i];
+ }
+
+ bool swapVals = false;
+ if (this.useOffsetMode)
+ {
+ if (this.blueContract)
+ {
+ swapVals = sum1 >= 0;
+ }
+ else
+ {
+ swapVals = sum1 < 0;
+ }
+
+ if (swapVals)
+ {
+ return false;
+ }
+ }
+ else
+ {
+ if (this.blueContract)
+ {
+ if (sum1 == sum0)
+ {
+ return false;
+ }
+
+ swapVals = sum1 > sum0;
+ needsWeightSwap = !needsWeightSwap;
+ }
+ else
+ {
+ swapVals = sum1 < sum0;
+ }
+ }
+
+ int[] quantizedLowOriginal = this.quantizedEndpoints.QuantizedLow();
+ int[] quantizedHighOriginal = this.quantizedEndpoints.QuantizedHigh();
+
+ int[] quantizedLow = (int[])quantizedLowOriginal.Clone();
+ int[] quantizedHigh = (int[])quantizedHighOriginal.Clone();
+
+ if (swapVals)
+ {
+ if (this.useOffsetMode)
+ {
+ throw new InvalidOperationException();
+ }
+
+ (quantizedHigh, quantizedLow) = (quantizedLow, quantizedHigh);
+ needsWeightSwap = !needsWeightSwap;
+ }
+
+ values[0] = quantizedLow[0];
+ values[1] = quantizedHigh[0];
+ values[2] = quantizedLow[1];
+ values[3] = quantizedHigh[1];
+ values[4] = quantizedLow[2];
+ values[5] = quantizedHigh[2];
+
+ if (this.useOffsetMode)
+ {
+ endpointMode = ColorEndpointMode.LdrRgbBaseOffset;
+ }
+ else
+ {
+ endpointMode = ColorEndpointMode.LdrRgbDirect;
+ }
+
+ if (hasAlpha)
+ {
+ values[6] = quantizedLow[3];
+ values[7] = quantizedHigh[3];
+ if (this.useOffsetMode)
+ {
+ endpointMode = ColorEndpointMode.LdrRgbaBaseOffset;
+ }
+ else
+ {
+ endpointMode = ColorEndpointMode.LdrRgbaDirect;
+ }
+ }
+
+ if (this.swapEndpoints)
+ {
+ needsWeightSwap = !needsWeightSwap;
+ }
+
+ return true;
+ }
+
+ public bool BlueContract() => this.blueContract;
+
+ public int Error() => this.squaredError;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncodingMode.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncodingMode.cs
new file mode 100644
index 00000000..adeb4bc6
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncodingMode.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal enum EndpointEncodingMode
+{
+ DirectLuma,
+ DirectLumaAlpha,
+ BaseScaleRgb,
+ BaseScaleRgba,
+ DirectRgb,
+ DirectRgba
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncodingModeExtensions.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncodingModeExtensions.cs
new file mode 100644
index 00000000..3f0597f3
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/EndpointEncodingModeExtensions.cs
@@ -0,0 +1,15 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal static class EndpointEncodingModeExtensions
+{
+ public static int GetValuesCount(this EndpointEncodingMode mode) => mode switch
+ {
+ EndpointEncodingMode.DirectLuma => 2,
+ EndpointEncodingMode.DirectLumaAlpha or EndpointEncodingMode.BaseScaleRgb => 4,
+ EndpointEncodingMode.DirectRgb or EndpointEncodingMode.BaseScaleRgba => 6,
+ _ => 8
+ };
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/HdrEndpointDecoder.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/HdrEndpointDecoder.cs
new file mode 100644
index 00000000..0077074f
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/HdrEndpointDecoder.cs
@@ -0,0 +1,478 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+///
+/// Decodes HDR (High Dynamic Range) color endpoints for ASTC texture compression.
+///
+///
+/// HDR modes produce 12-bit intermediate values (0-4095) which are shifted left by 4
+/// to produce the final 16-bit values (0-65520) stored as FP16 bit patterns.
+///
+internal static class HdrEndpointDecoder
+{
+ public static (Rgba64 Low, Rgba64 High) DecodeHdrMode(ReadOnlySpan values, int maxValue, ColorEndpointMode mode)
+ {
+ int count = mode.GetColorValuesCount();
+ Span unquantizedValues = stackalloc int[count];
+ int copyLength = Math.Min(count, values.Length);
+ for (int i = 0; i < copyLength; i++)
+ {
+ unquantizedValues[i] = Quantization.UnquantizeCEValueFromRange(values[i], maxValue);
+ }
+
+ return DecodeHdrModeUnquantized(unquantizedValues, mode);
+ }
+
+ ///
+ /// Decodes HDR endpoints from already-unquantized values.
+ /// Called from the fused decode path where BISE decode + batch unquantize
+ /// have already been performed.
+ ///
+ public static (Rgba64 Low, Rgba64 High) DecodeHdrModeUnquantized(ReadOnlySpan value, ColorEndpointMode mode) => mode switch
+ {
+ ColorEndpointMode.HdrLumaLargeRange => UnpackHdrLuminanceLargeRangeCore(value[0], value[1]),
+ ColorEndpointMode.HdrLumaSmallRange => UnpackHdrLuminanceSmallRangeCore(value[0], value[1]),
+ ColorEndpointMode.HdrRgbBaseScale => UnpackHdrRgbBaseScaleCore(value[0], value[1], value[2], value[3]),
+ ColorEndpointMode.HdrRgbDirect => UnpackHdrRgbDirectCore(value[0], value[1], value[2], value[3], value[4], value[5]),
+ ColorEndpointMode.HdrRgbDirectLdrAlpha => UnpackHdrRgbDirectLdrAlphaCore(value),
+ ColorEndpointMode.HdrRgbDirectHdrAlpha => UnpackHdrRgbDirectHdrAlphaCore(value),
+ _ => throw new InvalidOperationException($"Mode {mode} is not an HDR mode")
+ };
+
+ ///
+ /// Performs an unsigned left shift of a signed value, avoiding undefined behavior
+ /// that would occur with signed left shift of negative values.
+ ///
+ private static int SafeSignedLeftShift(int value, int shift) => (int)((uint)value << shift);
+
+ private static (Rgba64 Low, Rgba64 High) UnpackHdrLuminanceLargeRangeCore(int v0, int v1)
+ {
+ int y0, y1;
+ if (v1 >= v0)
+ {
+ y0 = v0 << 4;
+ y1 = v1 << 4;
+ }
+ else
+ {
+ y0 = (v1 << 4) + 8;
+ y1 = (v0 << 4) - 8;
+ }
+
+ Rgba64 low = new((ushort)(y0 << 4), (ushort)(y0 << 4), (ushort)(y0 << 4), 0x7800);
+ Rgba64 high = new((ushort)(y1 << 4), (ushort)(y1 << 4), (ushort)(y1 << 4), 0x7800);
+ return (low, high);
+ }
+
+ private static (Rgba64 Low, Rgba64 High) UnpackHdrLuminanceSmallRangeCore(int v0, int v1)
+ {
+ int y0, y1;
+ if ((v0 & 0x80) != 0)
+ {
+ y0 = ((v1 & 0xE0) << 4) | ((v0 & 0x7F) << 2);
+ y1 = (v1 & 0x1F) << 2;
+ }
+ else
+ {
+ y0 = ((v1 & 0xF0) << 4) | ((v0 & 0x7F) << 1);
+ y1 = (v1 & 0x0F) << 1;
+ }
+
+ y1 += y0;
+ if (y1 > 0xFFF)
+ {
+ y1 = 0xFFF;
+ }
+
+ Rgba64 low = new((ushort)(y0 << 4), (ushort)(y0 << 4), (ushort)(y0 << 4), 0x7800);
+ Rgba64 high = new((ushort)(y1 << 4), (ushort)(y1 << 4), (ushort)(y1 << 4), 0x7800);
+ return (low, high);
+ }
+
+ private static (Rgba64 Low, Rgba64 High) UnpackHdrRgbBaseScaleCore(int v0, int v1, int v2, int v3)
+ {
+ int modeValue = ((v0 & 0xC0) >> 6) | (((v1 & 0x80) >> 7) << 2) | (((v2 & 0x80) >> 7) << 3);
+
+ int majorComponent;
+ int mode;
+
+ (majorComponent, mode) = modeValue switch
+ {
+ _ when (modeValue & 0xC) != 0xC => (modeValue >> 2, modeValue & 3),
+ not 0xF => (modeValue & 3, 4),
+ _ => (0, 5)
+ };
+
+ int red = v0 & 0x3F;
+ int green = v1 & 0x1F;
+ int blue = v2 & 0x1F;
+ int scale = v3 & 0x1F;
+
+ int bit0 = (v1 >> 6) & 1;
+ int bit1 = (v1 >> 5) & 1;
+ int bit2 = (v2 >> 6) & 1;
+ int bit3 = (v2 >> 5) & 1;
+ int bit4 = (v3 >> 7) & 1;
+ int bit5 = (v3 >> 6) & 1;
+ int bit6 = (v3 >> 5) & 1;
+
+ int oneHotMode = 1 << mode;
+
+ if ((oneHotMode & 0x30) != 0)
+ {
+ green |= bit0 << 6;
+ }
+
+ if ((oneHotMode & 0x3A) != 0)
+ {
+ green |= bit1 << 5;
+ }
+
+ if ((oneHotMode & 0x30) != 0)
+ {
+ blue |= bit2 << 6;
+ }
+
+ if ((oneHotMode & 0x3A) != 0)
+ {
+ blue |= bit3 << 5;
+ }
+
+ if ((oneHotMode & 0x3D) != 0)
+ {
+ scale |= bit6 << 5;
+ }
+
+ if ((oneHotMode & 0x2D) != 0)
+ {
+ scale |= bit5 << 6;
+ }
+
+ if ((oneHotMode & 0x04) != 0)
+ {
+ scale |= bit4 << 7;
+ }
+
+ if ((oneHotMode & 0x3B) != 0)
+ {
+ red |= bit4 << 6;
+ }
+
+ if ((oneHotMode & 0x04) != 0)
+ {
+ red |= bit3 << 6;
+ }
+
+ if ((oneHotMode & 0x10) != 0)
+ {
+ red |= bit5 << 7;
+ }
+
+ if ((oneHotMode & 0x0F) != 0)
+ {
+ red |= bit2 << 7;
+ }
+
+ if ((oneHotMode & 0x05) != 0)
+ {
+ red |= bit1 << 8;
+ }
+
+ if ((oneHotMode & 0x0A) != 0)
+ {
+ red |= bit0 << 8;
+ }
+
+ if ((oneHotMode & 0x05) != 0)
+ {
+ red |= bit0 << 9;
+ }
+
+ if ((oneHotMode & 0x02) != 0)
+ {
+ red |= bit6 << 9;
+ }
+
+ if ((oneHotMode & 0x01) != 0)
+ {
+ red |= bit3 << 10;
+ }
+
+ if ((oneHotMode & 0x02) != 0)
+ {
+ red |= bit5 << 10;
+ }
+
+ // Shift amounts per mode (from ARM reference)
+ ReadOnlySpan shiftAmounts = [1, 1, 2, 3, 4, 5];
+ int shiftAmount = shiftAmounts[mode];
+
+ red <<= shiftAmount;
+ green <<= shiftAmount;
+ blue <<= shiftAmount;
+ scale <<= shiftAmount;
+
+ if (mode != 5)
+ {
+ green = red - green;
+ blue = red - blue;
+ }
+
+ // Swap components based on major component
+ (red, green, blue) = majorComponent switch
+ {
+ 1 => (green, red, blue),
+ 2 => (blue, green, red),
+ _ => (red, green, blue)
+ };
+
+ // Low endpoint is base minus scale offset
+ int red0 = red - scale;
+ int green0 = green - scale;
+ int blue0 = blue - scale;
+
+ // Clamp to [0, 0xFFF]
+ red = Math.Max(red, 0);
+ green = Math.Max(green, 0);
+ blue = Math.Max(blue, 0);
+ red0 = Math.Max(red0, 0);
+ green0 = Math.Max(green0, 0);
+ blue0 = Math.Max(blue0, 0);
+
+ Rgba64 low = new((ushort)(red0 << 4), (ushort)(green0 << 4), (ushort)(blue0 << 4), 0x7800);
+ Rgba64 high = new((ushort)(red << 4), (ushort)(green << 4), (ushort)(blue << 4), 0x7800);
+ return (low, high);
+ }
+
+ private static (Rgba64 Low, Rgba64 High) UnpackHdrRgbDirectCore(int v0, int v1, int v2, int v3, int v4, int v5)
+ {
+ int modeValue = ((v1 & 0x80) >> 7) | (((v2 & 0x80) >> 7) << 1) | (((v3 & 0x80) >> 7) << 2);
+ int majorComponent = ((v4 & 0x80) >> 7) | (((v5 & 0x80) >> 7) << 1);
+
+ // Special case: majorComponent == 3 (direct passthrough)
+ if (majorComponent == 3)
+ {
+ Rgba64 low = new(
+ (ushort)(v0 << 8),
+ (ushort)(v2 << 8),
+ (ushort)((v4 & 0x7F) << 9),
+ 0x7800);
+ Rgba64 high = new(
+ (ushort)(v1 << 8),
+ (ushort)(v3 << 8),
+ (ushort)((v5 & 0x7F) << 9),
+ 0x7800);
+ return (low, high);
+ }
+
+ int a = v0 | ((v1 & 0x40) << 2);
+ int b0 = v2 & 0x3F;
+ int b1 = v3 & 0x3F;
+ int c = v1 & 0x3F;
+ int d0 = v4 & 0x7F;
+ int d1 = v5 & 0x7F;
+
+ // Data bits table from ARM reference
+ ReadOnlySpan dataBitsTable = [7, 6, 7, 6, 5, 6, 5, 6];
+ int dataBits = dataBitsTable[modeValue];
+
+ int bit0 = (v2 >> 6) & 1;
+ int bit1 = (v3 >> 6) & 1;
+ int bit2 = (v4 >> 6) & 1;
+ int bit3 = (v5 >> 6) & 1;
+ int bit4 = (v4 >> 5) & 1;
+ int bit5 = (v5 >> 5) & 1;
+
+ int oneHotModeValue = 1 << modeValue;
+
+ // Bit placement for 'a'
+ if ((oneHotModeValue & 0xA4) != 0)
+ {
+ a |= bit0 << 9;
+ }
+
+ if ((oneHotModeValue & 0x8) != 0)
+ {
+ a |= bit2 << 9;
+ }
+
+ if ((oneHotModeValue & 0x50) != 0)
+ {
+ a |= bit4 << 9;
+ }
+
+ if ((oneHotModeValue & 0x50) != 0)
+ {
+ a |= bit5 << 10;
+ }
+
+ if ((oneHotModeValue & 0xA0) != 0)
+ {
+ a |= bit1 << 10;
+ }
+
+ if ((oneHotModeValue & 0xC0) != 0)
+ {
+ a |= bit2 << 11;
+ }
+
+ // Bit placement for 'c'
+ if ((oneHotModeValue & 0x4) != 0)
+ {
+ c |= bit1 << 6;
+ }
+
+ if ((oneHotModeValue & 0xE8) != 0)
+ {
+ c |= bit3 << 6;
+ }
+
+ if ((oneHotModeValue & 0x20) != 0)
+ {
+ c |= bit2 << 7;
+ }
+
+ // Bit placement for 'b0' and 'b1'
+ if ((oneHotModeValue & 0x5B) != 0)
+ {
+ b0 |= bit0 << 6;
+ b1 |= bit1 << 6;
+ }
+
+ if ((oneHotModeValue & 0x12) != 0)
+ {
+ b0 |= bit2 << 7;
+ b1 |= bit3 << 7;
+ }
+
+ // Bit placement for 'd0' and 'd1'
+ if ((oneHotModeValue & 0xAF) != 0)
+ {
+ d0 |= bit4 << 5;
+ d1 |= bit5 << 5;
+ }
+
+ if ((oneHotModeValue & 0x5) != 0)
+ {
+ d0 |= bit2 << 6;
+ d1 |= bit3 << 6;
+ }
+
+ // Sign-extend d0 and d1 based on dataBits
+ int signExtendShift = 32 - dataBits;
+ d0 = (d0 << signExtendShift) >> signExtendShift;
+ d1 = (d1 << signExtendShift) >> signExtendShift;
+
+ // Expand to 12 bits
+ int valueShift = (modeValue >> 1) ^ 3;
+ a = SafeSignedLeftShift(a, valueShift);
+ b0 = SafeSignedLeftShift(b0, valueShift);
+ b1 = SafeSignedLeftShift(b1, valueShift);
+ c = SafeSignedLeftShift(c, valueShift);
+ d0 = SafeSignedLeftShift(d0, valueShift);
+ d1 = SafeSignedLeftShift(d1, valueShift);
+
+ // Compute color values per ARM reference
+ int red1 = a;
+ int green1 = a - b0;
+ int blue1 = a - b1;
+ int red0 = a - c;
+ int green0 = a - b0 - c - d0;
+ int blue0 = a - b1 - c - d1;
+
+ // Clamp to [0, 4095]
+ red0 = Math.Clamp(red0, 0, 0xFFF);
+ green0 = Math.Clamp(green0, 0, 0xFFF);
+ blue0 = Math.Clamp(blue0, 0, 0xFFF);
+ red1 = Math.Clamp(red1, 0, 0xFFF);
+ green1 = Math.Clamp(green1, 0, 0xFFF);
+ blue1 = Math.Clamp(blue1, 0, 0xFFF);
+
+ // Swap components based on major component
+ (red0, green0, blue0, red1, green1, blue1) = majorComponent switch
+ {
+ 1 => (green0, red0, blue0, green1, red1, blue1),
+ 2 => (blue0, green0, red0, blue1, green1, red1),
+ _ => (red0, green0, blue0, red1, green1, blue1)
+ };
+
+ Rgba64 lowResult = new((ushort)(red0 << 4), (ushort)(green0 << 4), (ushort)(blue0 << 4), 0x7800);
+ Rgba64 highResult = new((ushort)(red1 << 4), (ushort)(green1 << 4), (ushort)(blue1 << 4), 0x7800);
+ return (lowResult, highResult);
+ }
+
+ private static (Rgba64 Low, Rgba64 High) UnpackHdrRgbDirectLdrAlphaCore(ReadOnlySpan unquantizedValues)
+ {
+ (Rgba64 rgbLow, Rgba64 rgbHigh) = UnpackHdrRgbDirectCore(unquantizedValues[0], unquantizedValues[1], unquantizedValues[2], unquantizedValues[3], unquantizedValues[4], unquantizedValues[5]);
+
+ ushort alpha0 = (ushort)(unquantizedValues[6] * 257);
+ ushort alpha1 = (ushort)(unquantizedValues[7] * 257);
+
+ Rgba64 low = new(rgbLow.R, rgbLow.G, rgbLow.B, alpha0);
+ Rgba64 high = new(rgbHigh.R, rgbHigh.G, rgbHigh.B, alpha1);
+ return (low, high);
+ }
+
+ private static (Rgba64 Low, Rgba64 High) UnpackHdrRgbDirectHdrAlphaCore(ReadOnlySpan unquantizedValues)
+ {
+ (Rgba64 rgbLow, Rgba64 rgbHigh) = UnpackHdrRgbDirectCore(unquantizedValues[0], unquantizedValues[1], unquantizedValues[2], unquantizedValues[3], unquantizedValues[4], unquantizedValues[5]);
+
+ (ushort alpha0, ushort alpha1) = UnpackHdrAlpha(unquantizedValues[6], unquantizedValues[7]);
+
+ Rgba64 low = new(rgbLow.R, rgbLow.G, rgbLow.B, alpha0);
+ Rgba64 high = new(rgbHigh.R, rgbHigh.G, rgbHigh.B, alpha1);
+ return (low, high);
+ }
+
+ ///
+ /// Decodes HDR alpha values
+ ///
+ private static (ushort Low, ushort High) UnpackHdrAlpha(int v6, int v7)
+ {
+ int selector = ((v6 >> 7) & 1) | ((v7 >> 6) & 2);
+ v6 &= 0x7F;
+ v7 &= 0x7F;
+
+ int a0, a1;
+
+ if (selector == 3)
+ {
+ // Simple mode: direct 7-bit values shifted to 12-bit
+ a0 = v6 << 5;
+ a1 = v7 << 5;
+ }
+ else
+ {
+ // Complex mode: base + sign-extended offset
+ v6 |= (v7 << (selector + 1)) & 0x780;
+ v7 &= 0x3F >> selector;
+ v7 ^= 32 >> selector;
+ v7 -= 32 >> selector;
+ v6 <<= 4 - selector;
+ v7 <<= 4 - selector;
+ v7 += v6;
+
+ if (v7 < 0)
+ {
+ v7 = 0;
+ }
+ else if (v7 > 0xFFF)
+ {
+ v7 = 0xFFF;
+ }
+
+ a0 = v6;
+ a1 = v7;
+ }
+
+ a0 = Math.Clamp(a0, 0, 0xFFF);
+ a1 = Math.Clamp(a1, 0, 0xFFF);
+
+ return ((ushort)(a0 << 4), (ushort)(a1 << 4));
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/Partition.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/Partition.cs
new file mode 100644
index 00000000..88cc5299
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/Partition.cs
@@ -0,0 +1,248 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal sealed class Partition
+{
+ private static readonly System.Collections.Concurrent.ConcurrentDictionary<(Footprint, int, int), Partition> PartitionCache = new();
+
+ public Partition(Footprint footprint, int partitionCount, int? id = null)
+ {
+ this.Footprint = footprint;
+ this.PartitionCount = partitionCount;
+ this.PartitionId = id;
+ this.Assignment = [];
+ }
+
+ public Footprint Footprint { get; set; }
+
+ public int PartitionCount { get; set; }
+
+ public int? PartitionId { get; set; }
+
+ public int[] Assignment { get; set; }
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not Partition other)
+ {
+ return false;
+ }
+
+ return PartitionMetric(this, other) == 0;
+ }
+
+ public override int GetHashCode() => HashCode.Combine(this.Footprint, this.PartitionCount, this.PartitionId);
+
+ public static int PartitionMetric(Partition a, Partition b)
+ {
+ ArgumentOutOfRangeException.ThrowIfNotEqual(a.Footprint, b.Footprint);
+
+ const int maxNumSubsets = 4;
+ int width = a.Footprint.Width;
+ int height = a.Footprint.Height;
+
+ List<(int A, int B, int Count)> pairCounts = [];
+ for (int y = 0; y < 4; ++y)
+ {
+ for (int x = 0; x < 4; ++x)
+ {
+ pairCounts.Add((x, y, 0));
+ }
+ }
+
+ for (int y = 0; y < height; ++y)
+ {
+ for (int x = 0; x < width; ++x)
+ {
+ int idx = (y * width) + x;
+ int aVal = a.Assignment[idx];
+ int bVal = b.Assignment[idx];
+ pairCounts[(bVal * 4) + aVal] = (aVal, bVal, pairCounts[(bVal * 4) + aVal].Count + 1);
+ }
+ }
+
+ List<(int A, int B, int Count)> sorted = [.. pairCounts.OrderByDescending(p => p.Count)];
+ bool[,] assigned = new bool[maxNumSubsets, maxNumSubsets];
+ int pixelsMatched = 0;
+ foreach ((int pairA, int pairB, int count) in sorted)
+ {
+ bool isAssigned = false;
+ for (int i = 0; i < maxNumSubsets; ++i)
+ {
+ if (assigned[pairA, i] || assigned[i, pairB])
+ {
+ isAssigned = true;
+ break;
+ }
+ }
+
+ if (!isAssigned)
+ {
+ assigned[pairA, pairB] = true;
+ pixelsMatched += count;
+ }
+ }
+
+ return (width * height) - pixelsMatched;
+ }
+
+ // Basic GetASTCPartition implementation using selection function from C++
+ public static Partition GetASTCPartition(Footprint footprint, int partitionCount, int partitionId)
+ {
+ (Footprint Footprint, int PartitionCount, int PartitionId) key = (footprint, partitionCount, partitionId);
+ if (PartitionCache.TryGetValue(key, out Partition? cached))
+ {
+ return cached;
+ }
+
+ Partition part = new(footprint, partitionCount, partitionId);
+ int w = footprint.Width;
+ int h = footprint.Height;
+ int[] assignment = new int[w * h];
+ int idx = 0;
+ for (int y = 0; y < h; ++y)
+ {
+ for (int x = 0; x < w; ++x)
+ {
+ assignment[idx++] = SelectASTCPartition(partitionId, x, y, 0, partitionCount, footprint.PixelCount);
+ }
+ }
+
+ part.Assignment = assignment;
+ PartitionCache.TryAdd(key, part);
+ return part;
+ }
+
+ // For now, implement a naive FindClosestASTCPartition that just checks a
+ // small set of candidate partitions generated by enum footprints. We'll
+ // return the same footprint size partitions with ID=0 for simplicity.
+ public static Partition FindClosestASTCPartition(Partition candidate)
+ {
+ // Search a few partitions and pick the one with minimal PartitionMetric
+ Partition best = GetASTCPartition(candidate.Footprint, Math.Max(1, candidate.PartitionCount), 0);
+ int bestDist = PartitionMetric(best, candidate);
+ return best;
+ }
+
+ // Very small port of selection function; behavior taken from C++ file.
+ private static int SelectASTCPartition(int seed, int x, int y, int z, int partitionCount, int pixelCount)
+ {
+ if (partitionCount <= 1)
+ {
+ return 0;
+ }
+
+ if (pixelCount < 31)
+ {
+ x <<= 1;
+ y <<= 1;
+ z <<= 1;
+ }
+
+ seed += (partitionCount - 1) * 1024;
+ uint randomNumber = (uint)seed;
+ randomNumber ^= randomNumber >> 15;
+ randomNumber -= randomNumber << 17;
+ randomNumber += randomNumber << 7;
+ randomNumber += randomNumber << 4;
+ randomNumber ^= randomNumber >> 5;
+ randomNumber += randomNumber << 16;
+ randomNumber ^= randomNumber >> 7;
+ randomNumber ^= randomNumber >> 3;
+ randomNumber ^= randomNumber << 6;
+ randomNumber ^= randomNumber >> 17;
+
+ uint seed1 = randomNumber & 0xF;
+ uint seed2 = (randomNumber >> 4) & 0xF;
+ uint seed3 = (randomNumber >> 8) & 0xF;
+ uint seed4 = (randomNumber >> 12) & 0xF;
+ uint seed5 = (randomNumber >> 16) & 0xF;
+ uint seed6 = (randomNumber >> 20) & 0xF;
+ uint seed7 = (randomNumber >> 24) & 0xF;
+ uint seed8 = (randomNumber >> 28) & 0xF;
+ uint seed9 = (randomNumber >> 18) & 0xF;
+ uint seed10 = (randomNumber >> 22) & 0xF;
+ uint seed11 = (randomNumber >> 26) & 0xF;
+ uint seed12 = ((randomNumber >> 30) | (randomNumber << 2)) & 0xF;
+
+ seed1 *= seed1;
+ seed2 *= seed2;
+ seed3 *= seed3;
+ seed4 *= seed4;
+ seed5 *= seed5;
+ seed6 *= seed6;
+ seed7 *= seed7;
+ seed8 *= seed8;
+ seed9 *= seed9;
+ seed10 *= seed10;
+ seed11 *= seed11;
+ seed12 *= seed12;
+
+ int sh1, sh2, sh3;
+ if ((seed & 1) != 0)
+ {
+ sh1 = (seed & 2) != 0 ? 4 : 5;
+ sh2 = (partitionCount == 3) ? 6 : 5;
+ }
+ else
+ {
+ sh1 = (partitionCount == 3) ? 6 : 5;
+ sh2 = (seed & 2) != 0 ? 4 : 5;
+ }
+
+ sh3 = (seed & 0x10) != 0 ? sh1 : sh2;
+
+ seed1 >>= sh1;
+ seed2 >>= sh2;
+ seed3 >>= sh1;
+ seed4 >>= sh2;
+ seed5 >>= sh1;
+ seed6 >>= sh2;
+ seed7 >>= sh1;
+ seed8 >>= sh2;
+ seed9 >>= sh3;
+ seed10 >>= sh3;
+ seed11 >>= sh3;
+ seed12 >>= sh3;
+
+ int a = (int)((seed1 * x) + (seed2 * y) + (seed11 * z) + (randomNumber >> 14));
+ int b = (int)((seed3 * x) + (seed4 * y) + (seed12 * z) + (randomNumber >> 10));
+ int c = (int)((seed5 * x) + (seed6 * y) + (seed9 * z) + (randomNumber >> 6));
+ int d = (int)((seed7 * x) + (seed8 * y) + (seed10 * z) + (randomNumber >> 2));
+
+ a &= 0x3F;
+ b &= 0x3F;
+ c &= 0x3F;
+ d &= 0x3F;
+ if (partitionCount <= 3)
+ {
+ d = 0;
+ }
+
+ if (partitionCount <= 2)
+ {
+ c = 0;
+ }
+
+ if (a >= b && a >= c && a >= d)
+ {
+ return 0;
+ }
+ else if (b >= c && b >= d)
+ {
+ return 1;
+ }
+ else if (c >= d)
+ {
+ return 2;
+ }
+ else
+ {
+ return 3;
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/RgbaColorExtensions.cs b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/RgbaColorExtensions.cs
new file mode 100644
index 00000000..5ba859da
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/ColorEncoding/RgbaColorExtensions.cs
@@ -0,0 +1,56 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using static SixLabors.ImageSharp.Textures.Compression.Astc.Core.Rgba32Extensions;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+
+internal static class RgbaColorExtensions
+{
+ ///
+ /// Uses the value in the blue channel to tint the red and green
+ ///
+ ///
+ /// Applies the 'blue_contract' function defined in Section C.2.14 of the ASTC specification.
+ ///
+ public static Rgba32 WithBlueContract(int red, int green, int blue, int alpha)
+ => ClampedRgba32(
+ r: (red + blue) >> 1,
+ g: (green + blue) >> 1,
+ b: blue,
+ a: alpha);
+
+ ///
+ /// Uses the value in the blue channel to tint the red and green
+ ///
+ ///
+ /// Applies the 'blue_contract' function defined in Section C.2.14 of the ASTC specification.
+ ///
+ public static Rgba32 WithBlueContract(this Rgba32 color)
+ => WithBlueContract(color.R, color.G, color.B, color.A);
+
+ ///
+ /// The inverse of
+ ///
+ public static Rgba32 WithInvertedBlueContract(this Rgba32 color)
+ => ClampedRgba32(
+ r: (2 * color.R) - color.B,
+ g: (2 * color.G) - color.B,
+ b: color.B,
+ a: color.A);
+
+ public static Rgba32 AsOffsetFrom(this Rgba32 color, Rgba32 baseColor)
+ {
+ int[] offset = [color.R, color.G, color.B, color.A];
+
+ for (int i = 0; i < 4; ++i)
+ {
+ (int a, int b) = BitOperations.TransferPrecision(offset[i], baseColor.GetChannel(i));
+ offset[i] = Math.Clamp(baseColor.GetChannel(i) + a, byte.MinValue, byte.MaxValue);
+ }
+
+ return ClampedRgba32(offset[0], offset[1], offset[2], offset[3]);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/BitOperations.cs b/src/ImageSharp.Textures/Compression/Astc/Core/BitOperations.cs
new file mode 100644
index 00000000..354f2117
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/BitOperations.cs
@@ -0,0 +1,107 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+internal static class BitOperations
+{
+ ///
+ /// Return the specified range as a (low bits in lower 64 bits)
+ ///
+ public static UInt128 GetBits(UInt128 value, int start, int length)
+ {
+ if (length <= 0)
+ {
+ return UInt128.Zero;
+ }
+
+ UInt128 shifted = value >> start;
+ if (length >= 128)
+ {
+ return shifted;
+ }
+
+ if (length >= 64)
+ {
+ ulong lowMask = ~0UL;
+ int highBits = length - 64;
+ ulong highMask = (highBits == 64)
+ ? ~0UL
+ : ((1UL << highBits) - 1UL);
+
+ return new UInt128(shifted.High() & highMask, shifted.Low() & lowMask);
+ }
+ else
+ {
+ ulong mask = (length == 64)
+ ? ~0UL
+ : ((1UL << length) - 1UL);
+
+ return new UInt128(0, shifted.Low() & mask);
+ }
+ }
+
+ ///
+ /// Return the specified range as a ulong
+ ///
+ public static ulong GetBits(ulong value, int start, int length)
+ {
+ if (length <= 0)
+ {
+ return 0UL;
+ }
+
+ int totalBits = sizeof(ulong) * 8;
+ ulong mask = length == totalBits
+ ? ~0UL
+ : ~0UL >> (totalBits - length);
+
+ return (value >> start) & mask;
+ }
+
+ ///
+ /// Transfers a few bits of precision from one value to another.
+ ///
+ ///
+ /// The 'bit_transfer_signed' function defined in Section C.2.14 of the ASTC specification
+ ///
+ public static (int A, int B) TransferPrecision(int a, int b)
+ {
+ b >>= 1;
+ b |= a & 0x80;
+ a >>= 1;
+ a &= 0x3F;
+
+ if ((a & 0x20) != 0)
+ {
+ a -= 0x40;
+ }
+
+ return (a, b);
+ }
+
+ ///
+ /// Takes two values, |a| in the range [-32, 31], and |b| in the range [0, 255],
+ /// and returns the two values in [0, 255] that will reconstruct |a| and |b| when
+ /// passed to the function.
+ ///
+ public static (int A, int B) TransferPrecisionInverse(int a, int b)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(a, -32);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(a, 31);
+ ArgumentOutOfRangeException.ThrowIfLessThan(b, byte.MinValue);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(b, byte.MaxValue);
+
+ if (a < 0)
+ {
+ a += 0x40;
+ }
+
+ a <<= 1;
+ a |= b & 0x80;
+ b <<= 1;
+ b &= 0xff;
+
+ return (a, b);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/DecimationInfo.cs b/src/ImageSharp.Textures/Compression/Astc/Core/DecimationInfo.cs
new file mode 100644
index 00000000..76da48fb
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/DecimationInfo.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+///
+/// Pre-computed weight infill data for a specific (footprint, weightGridX, weightGridY) combination.
+/// Stores bilinear interpolation indices and factors in a transposed layout.
+///
+internal sealed class DecimationInfo
+{
+ // Transposed layout: [contribution * TexelCount + texel]
+ // 4 contributions per texel (bilinear interpolation from weight grid).
+ // For edge texels where some grid points are out of bounds, factor is 0 and index is 0.
+ public DecimationInfo(int texelCount, int[] weightIndices, int[] weightFactors)
+ {
+ this.TexelCount = texelCount;
+ this.WeightIndices = weightIndices;
+ this.WeightFactors = weightFactors;
+ }
+
+ public int TexelCount { get; }
+
+ public int[] WeightIndices { get; }
+
+ public int[] WeightFactors { get; }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/DecimationTable.cs b/src/ImageSharp.Textures/Compression/Astc/Core/DecimationTable.cs
new file mode 100644
index 00000000..4c375c37
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/DecimationTable.cs
@@ -0,0 +1,140 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+///
+/// Caches pre-computed DecimationInfo tables and provides weight infill.
+/// For each unique (footprint, gridX, gridY) combination, the bilinear interpolation
+/// indices and factors are computed once and reused for every block with that configuration.
+/// Uses a flat array indexed by (footprintType, gridX, gridY) for O(1) lookup.
+///
+internal static class DecimationTable
+{
+ // Grid dimensions range from 2 to 12 inclusive
+ private const int GridMin = 2;
+ private const int GridRange = 11; // 12 - 2 + 1
+ private const int FootprintCount = 14;
+ private static readonly DecimationInfo?[] Table = new DecimationInfo?[FootprintCount * GridRange * GridRange];
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static DecimationInfo Get(Footprint footprint, int gridX, int gridY)
+ {
+ int index = ((int)footprint.Type * GridRange * GridRange) + ((gridX - GridMin) * GridRange) + (gridY - GridMin);
+ DecimationInfo? decimationInfo = Table[index];
+ if (decimationInfo is null)
+ {
+ decimationInfo = Compute(footprint.Width, footprint.Height, gridX, gridY);
+ Table[index] = decimationInfo;
+ }
+
+ return decimationInfo;
+ }
+
+ ///
+ /// Performs weight infill using pre-computed tables.
+ /// Maps unquantized grid weights to per-texel weights via bilinear interpolation
+ /// with pre-computed indices and factors.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ public static void InfillWeights(ReadOnlySpan gridWeights, DecimationInfo decimationInfo, Span result)
+ {
+ int texelCount = decimationInfo.TexelCount;
+ int[] weightIndices = decimationInfo.WeightIndices;
+ int[] weightFactors = decimationInfo.WeightFactors;
+ int offset1 = texelCount, offset2 = texelCount * 2, offset3 = texelCount * 3;
+
+ for (int i = 0; i < texelCount; i++)
+ {
+ result[i] = (8
+ + (gridWeights[weightIndices[i]] * weightFactors[i])
+ + (gridWeights[weightIndices[offset1 + i]] * weightFactors[offset1 + i])
+ + (gridWeights[weightIndices[offset2 + i]] * weightFactors[offset2 + i])
+ + (gridWeights[weightIndices[offset3 + i]] * weightFactors[offset3 + i])) >> 4;
+ }
+ }
+
+ private static int GetScaleFactorD(int blockDimensions) => (int)((1024f + (blockDimensions >> 1)) / (blockDimensions - 1));
+
+ private static DecimationInfo Compute(int footprintWidth, int footprintHeight, int gridWidth, int gridHeight)
+ {
+ int texelCount = footprintWidth * footprintHeight;
+
+ int[] indices = new int[4 * texelCount];
+ int[] factors = new int[4 * texelCount];
+
+ int scaleHorizontal = GetScaleFactorD(footprintWidth);
+ int scaleVertical = GetScaleFactorD(footprintHeight);
+ int gridLimit = gridWidth * gridHeight;
+ int maxGridX = gridWidth - 1;
+ int maxGridY = gridHeight - 1;
+
+ int texelIndex = 0;
+ for (int texelY = 0; texelY < footprintHeight; ++texelY)
+ {
+ int scaledY = scaleVertical * texelY;
+ int gridY = ((scaledY * maxGridY) + 32) >> 6;
+ int gridRowIndex = gridY >> 4;
+ int fractionY = gridY & 0xF;
+
+ for (int texelX = 0; texelX < footprintWidth; ++texelX)
+ {
+ int scaledX = scaleHorizontal * texelX;
+ int gridX = ((scaledX * maxGridX) + 32) >> 6;
+ int gridColIndex = gridX >> 4;
+ int fractionX = gridX & 0xF;
+
+ int gridPoint0 = gridColIndex + (gridWidth * gridRowIndex);
+ int gridPoint1 = gridPoint0 + 1;
+ int gridPoint2 = gridColIndex + (gridWidth * (gridRowIndex + 1));
+ int gridPoint3 = gridPoint2 + 1;
+
+ int factor3 = ((fractionX * fractionY) + 8) >> 4;
+ int factor2 = fractionY - factor3;
+ int factor1 = fractionX - factor3;
+ int factor0 = 16 - fractionX - fractionY + factor3;
+
+ // For out-of-bounds grid points, zero the factor and use index 0 (safe dummy)
+ if (gridPoint3 >= gridLimit)
+ {
+ factor3 = 0;
+ gridPoint3 = 0;
+ }
+
+ if (gridPoint2 >= gridLimit)
+ {
+ factor2 = 0;
+ gridPoint2 = 0;
+ }
+
+ if (gridPoint1 >= gridLimit)
+ {
+ factor1 = 0;
+ gridPoint1 = 0;
+ }
+
+ if (gridPoint0 >= gridLimit)
+ {
+ factor0 = 0;
+ gridPoint0 = 0;
+ }
+
+ indices[(0 * texelCount) + texelIndex] = gridPoint0;
+ indices[(1 * texelCount) + texelIndex] = gridPoint1;
+ indices[(2 * texelCount) + texelIndex] = gridPoint2;
+ indices[(3 * texelCount) + texelIndex] = gridPoint3;
+
+ factors[(0 * texelCount) + texelIndex] = factor0;
+ factors[(1 * texelCount) + texelIndex] = factor1;
+ factors[(2 * texelCount) + texelIndex] = factor2;
+ factors[(3 * texelCount) + texelIndex] = factor3;
+
+ texelIndex++;
+ }
+ }
+
+ return new DecimationInfo(texelCount, indices, factors);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/Footprint.cs b/src/ImageSharp.Textures/Compression/Astc/Core/Footprint.cs
new file mode 100644
index 00000000..028f82f7
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/Footprint.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+///
+/// Represents the dimensions of an ASTC block footprint.
+///
+public readonly record struct Footprint
+{
+ private Footprint(FootprintType type, int width, int height)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(width);
+ ArgumentOutOfRangeException.ThrowIfNegative(height);
+
+ this.Type = type;
+ this.Width = width;
+ this.Height = height;
+ this.PixelCount = width * height;
+ }
+
+ /// Gets the block width in texels.
+ public int Width { get; }
+
+ /// Gets the block height in texels.
+ public int Height { get; }
+
+ /// Gets the footprint type enum value.
+ public FootprintType Type { get; }
+
+ /// Gets the total number of texels in the block (Width * Height).
+ public int PixelCount { get; }
+
+ ///
+ /// Creates a from the specified .
+ ///
+ /// The footprint type to create a footprint from.
+ /// A matching the specified type.
+ public static Footprint FromFootprintType(FootprintType type) => type switch
+ {
+ FootprintType.Footprint4x4 => Get4x4(),
+ FootprintType.Footprint5x4 => Get5x4(),
+ FootprintType.Footprint5x5 => Get5x5(),
+ FootprintType.Footprint6x5 => Get6x5(),
+ FootprintType.Footprint6x6 => Get6x6(),
+ FootprintType.Footprint8x5 => Get8x5(),
+ FootprintType.Footprint8x6 => Get8x6(),
+ FootprintType.Footprint8x8 => Get8x8(),
+ FootprintType.Footprint10x5 => Get10x5(),
+ FootprintType.Footprint10x6 => Get10x6(),
+ FootprintType.Footprint10x8 => Get10x8(),
+ FootprintType.Footprint10x10 => Get10x10(),
+ FootprintType.Footprint12x10 => Get12x10(),
+ FootprintType.Footprint12x12 => Get12x12(),
+ _ => throw new ArgumentOutOfRangeException($"Invalid FootprintType: {type}"),
+ };
+
+ internal static Footprint Get4x4() => new(FootprintType.Footprint4x4, 4, 4);
+
+ internal static Footprint Get5x4() => new(FootprintType.Footprint5x4, 5, 4);
+
+ internal static Footprint Get5x5() => new(FootprintType.Footprint5x5, 5, 5);
+
+ internal static Footprint Get6x5() => new(FootprintType.Footprint6x5, 6, 5);
+
+ internal static Footprint Get6x6() => new(FootprintType.Footprint6x6, 6, 6);
+
+ internal static Footprint Get8x5() => new(FootprintType.Footprint8x5, 8, 5);
+
+ internal static Footprint Get8x6() => new(FootprintType.Footprint8x6, 8, 6);
+
+ internal static Footprint Get8x8() => new(FootprintType.Footprint8x8, 8, 8);
+
+ internal static Footprint Get10x5() => new(FootprintType.Footprint10x5, 10, 5);
+
+ internal static Footprint Get10x6() => new(FootprintType.Footprint10x6, 10, 6);
+
+ internal static Footprint Get10x8() => new(FootprintType.Footprint10x8, 10, 8);
+
+ internal static Footprint Get10x10() => new(FootprintType.Footprint10x10, 10, 10);
+
+ internal static Footprint Get12x10() => new(FootprintType.Footprint12x10, 12, 10);
+
+ internal static Footprint Get12x12() => new(FootprintType.Footprint12x12, 12, 12);
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/FootprintType.cs b/src/ImageSharp.Textures/Compression/Astc/Core/FootprintType.cs
new file mode 100644
index 00000000..381d3510
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/FootprintType.cs
@@ -0,0 +1,52 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+///
+/// The supported ASTC block footprint sizes.
+///
+public enum FootprintType
+{
+ /// 4x4 texel block.
+ Footprint4x4,
+
+ /// 5x4 texel block.
+ Footprint5x4,
+
+ /// 5x5 texel block.
+ Footprint5x5,
+
+ /// 6x5 texel block.
+ Footprint6x5,
+
+ /// 6x6 texel block.
+ Footprint6x6,
+
+ /// 8x5 texel block.
+ Footprint8x5,
+
+ /// 8x6 texel block.
+ Footprint8x6,
+
+ /// 8x8 texel block.
+ Footprint8x8,
+
+ /// 10x5 texel block.
+ Footprint10x5,
+
+ /// 10x6 texel block.
+ Footprint10x6,
+
+ /// 10x8 texel block.
+ Footprint10x8,
+
+ /// 10x10 texel block.
+ Footprint10x10,
+
+ /// 12x10 texel block.
+ Footprint12x10,
+
+ /// 12x12 texel block.
+ Footprint12x12,
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/Rgba32Extensions.cs b/src/ImageSharp.Textures/Compression/Astc/Core/Rgba32Extensions.cs
new file mode 100644
index 00000000..c9adb5d5
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/Rgba32Extensions.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+///
+/// ASTC-specific extension methods and helpers for .
+///
+internal static class Rgba32Extensions
+{
+ ///
+ /// Creates an from integer values, clamping each channel to [0, 255].
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Rgba32 ClampedRgba32(int r, int g, int b, int a = byte.MaxValue)
+ => new(
+ (byte)Math.Clamp(r, byte.MinValue, byte.MaxValue),
+ (byte)Math.Clamp(g, byte.MinValue, byte.MaxValue),
+ (byte)Math.Clamp(b, byte.MinValue, byte.MaxValue),
+ (byte)Math.Clamp(a, byte.MinValue, byte.MaxValue));
+
+ ///
+ /// Gets the rounded arithmetic mean of the R, G, and B channels.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static byte GetAverage(this Rgba32 color)
+ {
+ int sum = color.R + color.G + color.B;
+ return (byte)(((sum * 256) + 384) / 768);
+ }
+
+ ///
+ /// Gets the channel value at the specified index: 0=R, 1=G, 2=B, 3=A.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static int GetChannel(this Rgba32 color, int i)
+ => i switch
+ {
+ 0 => color.R,
+ 1 => color.G,
+ 2 => color.B,
+ 3 => color.A,
+ _ => throw new ArgumentOutOfRangeException(nameof(i), $"Index must be between 0 and 3. Actual value: {i}.")
+ };
+
+ ///
+ /// Computes the sum of squared per-channel differences across all four RGBA channels.
+ ///
+ public static int SquaredError(Rgba32 a, Rgba32 b)
+ {
+ int dr = a.R - b.R;
+ int dg = a.G - b.G;
+ int db = a.B - b.B;
+ int da = a.A - b.A;
+ return (dr * dr) + (dg * dg) + (db * db) + (da * da);
+ }
+
+ ///
+ /// Computes the sum of squared per-channel differences for the RGB channels only, ignoring alpha.
+ ///
+ public static int SquaredErrorRgb(Rgba32 a, Rgba32 b)
+ {
+ int dr = a.R - b.R;
+ int dg = a.G - b.G;
+ int db = a.B - b.B;
+ return (dr * dr) + (dg * dg) + (db * db);
+ }
+
+ ///
+ /// Returns true if all four channels are within the specified tolerance of the other color.
+ ///
+ public static bool IsCloseTo(this Rgba32 color, Rgba32 other, int tolerance)
+ => Math.Abs(color.R - other.R) <= tolerance &&
+ Math.Abs(color.G - other.G) <= tolerance &&
+ Math.Abs(color.B - other.B) <= tolerance &&
+ Math.Abs(color.A - other.A) <= tolerance;
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/Rgba64Extensions.cs b/src/ImageSharp.Textures/Compression/Astc/Core/Rgba64Extensions.cs
new file mode 100644
index 00000000..d792431c
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/Rgba64Extensions.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+///
+/// ASTC-specific extension methods and helpers for .
+///
+internal static class Rgba64Extensions
+{
+ ///
+ /// Gets the channel value at the specified index: 0=R, 1=G, 2=B, 3=A.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static ushort GetChannel(this Rgba64 color, int i)
+ => i switch
+ {
+ 0 => color.R,
+ 1 => color.G,
+ 2 => color.B,
+ 3 => color.A,
+ _ => throw new ArgumentOutOfRangeException(nameof(i), $"Index must be between 0 and 3. Actual value: {i}.")
+ };
+
+ ///
+ /// Returns true if all four channels are within the specified tolerance of the other color.
+ ///
+ public static bool IsCloseTo(this Rgba64 color, Rgba64 other, int tolerance)
+ => Math.Abs(color.R - other.R) <= tolerance &&
+ Math.Abs(color.G - other.G) <= tolerance &&
+ Math.Abs(color.B - other.B) <= tolerance &&
+ Math.Abs(color.A - other.A) <= tolerance;
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/SimdHelpers.cs b/src/ImageSharp.Textures/Compression/Astc/Core/SimdHelpers.cs
new file mode 100644
index 00000000..a09f021c
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/SimdHelpers.cs
@@ -0,0 +1,177 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using System.Runtime.Intrinsics;
+using SixLabors.ImageSharp.PixelFormats;
+using static SixLabors.ImageSharp.Textures.Compression.Astc.Core.Rgba32Extensions;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+internal static class SimdHelpers
+{
+ private static readonly Vector128 Vec32 = Vector128.Create(32);
+ private static readonly Vector128 Vec64 = Vector128.Create(64);
+ private static readonly Vector128 Vec255 = Vector128.Create(255);
+ private static readonly Vector128 Vec32767 = Vector128.Create(32767);
+
+ ///
+ /// Interpolates one channel for 4 pixels simultaneously.
+ /// All 4 pixels share the same endpoint values but have different weights.
+ /// Returns 4 byte results packed into the lower bytes of a .
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Vector128 Interpolate4ChannelPixels(int p0, int p1, Vector128 weights)
+ {
+ // Bit-replicate endpoint bytes to 16-bit
+ Vector128 c0 = Vector128.Create((p0 << 8) | p0);
+ Vector128 c1 = Vector128.Create((p1 << 8) | p1);
+
+ // c = (c0 * (64 - w) + c1 * w + 32) >> 6
+ // NOTE: Using >> 6 instead of / 64 because Vector128 division
+ // has no hardware support and decomposes to scalar operations.
+ Vector128 w64 = Vec64 - weights;
+ Vector128 c = ((c0 * w64) + (c1 * weights) + Vec32) >> 6;
+
+ // Quantize: (c * 255 + 32767) >> 16, clamped to [0, 255]
+ Vector128 result = ((c * Vec255) + Vec32767) >>> 16;
+ return Vector128.Min(Vector128.Max(result, Vector128.Zero), Vec255);
+ }
+
+ ///
+ /// Writes 4 LDR pixels directly to output buffer using SIMD.
+ /// Processes each channel across 4 pixels in parallel, then interleaves to RGBA output.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void Write4PixelLdr(
+ Span output,
+ int offset,
+ int lowR,
+ int lowG,
+ int lowB,
+ int lowA,
+ int highR,
+ int highG,
+ int highB,
+ int highA,
+ Vector128 weights)
+ {
+ Vector128 r = Interpolate4ChannelPixels(lowR, highR, weights);
+ Vector128 g = Interpolate4ChannelPixels(lowG, highG, weights);
+ Vector128 b = Interpolate4ChannelPixels(lowB, highB, weights);
+ Vector128 a = Interpolate4ChannelPixels(lowA, highA, weights);
+
+ // Pack 4 RGBA pixels into 16 bytes via vector OR+shift.
+ // Each int element has its channel value in bits [0:7].
+ // Combine: element[i] = R[i] | (G[i] << 8) | (B[i] << 16) | (A[i] << 24)
+ // On little-endian, storing this int32 writes bytes [R, G, B, A].
+ Vector128 rgba = r | (g << 8) | (b << 16) | (a << 24);
+ rgba.AsByte().CopyTo(output.Slice(offset, 16));
+ }
+
+ ///
+ /// Scalar single-pixel LDR interpolation, writing directly to buffer.
+ /// No Rgba32 allocation.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void WriteSinglePixelLdr(
+ Span output,
+ int offset,
+ int lowR,
+ int lowG,
+ int lowB,
+ int lowA,
+ int highR,
+ int highG,
+ int highB,
+ int highA,
+ int weight)
+ {
+ output[offset + 0] = (byte)InterpolateChannelScalar(lowR, highR, weight);
+ output[offset + 1] = (byte)InterpolateChannelScalar(lowG, highG, weight);
+ output[offset + 2] = (byte)InterpolateChannelScalar(lowB, highB, weight);
+ output[offset + 3] = (byte)InterpolateChannelScalar(lowA, highA, weight);
+ }
+
+ ///
+ /// Scalar single-pixel dual-plane LDR interpolation, writing directly to buffer.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static void WriteSinglePixelLdrDualPlane(
+ Span output,
+ int offset,
+ int lowR,
+ int lowG,
+ int lowB,
+ int lowA,
+ int highR,
+ int highG,
+ int highB,
+ int highA,
+ int weight,
+ int dpChannel,
+ int dpWeight)
+ {
+ output[offset + 0] = (byte)InterpolateChannelScalar(
+ lowR,
+ highR,
+ dpChannel == 0 ? dpWeight : weight);
+ output[offset + 1] = (byte)InterpolateChannelScalar(
+ lowG,
+ highG,
+ dpChannel == 1 ? dpWeight : weight);
+ output[offset + 2] = (byte)InterpolateChannelScalar(
+ lowB,
+ highB,
+ dpChannel == 2 ? dpWeight : weight);
+ output[offset + 3] = (byte)InterpolateChannelScalar(
+ lowA,
+ highA,
+ dpChannel == 3 ? dpWeight : weight);
+ }
+
+ // Keep the old API for ColorAt() (used by tests and non-hot paths)
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Rgba32 InterpolateColorLdr(Rgba32 low, Rgba32 high, int weight)
+ => ClampedRgba32(
+ r: InterpolateChannelScalar(low.R, high.R, weight),
+ g: InterpolateChannelScalar(low.G, high.G, weight),
+ b: InterpolateChannelScalar(low.B, high.B, weight),
+ a: InterpolateChannelScalar(low.A, high.A, weight));
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Rgba32 InterpolateColorLdrDualPlane(
+ Rgba32 low,
+ Rgba32 high,
+ int weight,
+ int dualPlaneChannel,
+ int dualPlaneWeight)
+ => ClampedRgba32(
+ r: InterpolateChannelScalar(
+ low.R,
+ high.R,
+ dualPlaneChannel == 0 ? dualPlaneWeight : weight),
+ g: InterpolateChannelScalar(
+ low.G,
+ high.G,
+ dualPlaneChannel == 1 ? dualPlaneWeight : weight),
+ b: InterpolateChannelScalar(
+ low.B,
+ high.B,
+ dualPlaneChannel == 2 ? dualPlaneWeight : weight),
+ a: InterpolateChannelScalar(
+ low.A,
+ high.A,
+ dualPlaneChannel == 3 ? dualPlaneWeight : weight));
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ internal static int InterpolateChannelScalar(int p0, int p1, int weight)
+ {
+ int c0 = (p0 << 8) | p0;
+ int c1 = (p1 << 8) | p1;
+ int c = ((c0 * (64 - weight)) + (c1 * weight) + 32) / 64;
+ int quantized = ((c * 255) + 32767) / 65536;
+
+ return Math.Clamp(quantized, 0, 255);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/Core/UInt128Extensions.cs b/src/ImageSharp.Textures/Compression/Astc/Core/UInt128Extensions.cs
new file mode 100644
index 00000000..4f423c19
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/Core/UInt128Extensions.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+internal static class UInt128Extensions
+{
+ ///
+ /// The lower 64 bits of the value
+ ///
+ public static ulong Low(this UInt128 value)
+ => (ulong)(value & 0xFFFFFFFFFFFFFFFFUL);
+
+ ///
+ /// The upper 64 bits of the value
+ ///
+ public static ulong High(this UInt128 value)
+ => (ulong)(value >> 64);
+
+ ///
+ /// A mask with the lowest n bits set to 1
+ ///
+ public static UInt128 OnesMask(int n)
+ {
+ if (n <= 0)
+ {
+ return UInt128.Zero;
+ }
+
+ if (n >= 128)
+ {
+ return new UInt128(~0UL, ~0UL);
+ }
+
+ if (n <= 64)
+ {
+ ulong low = (n == 64)
+ ? ~0UL
+ : ((1UL << n) - 1UL);
+
+ return new UInt128(0UL, low);
+ }
+ else
+ {
+ int highBits = n - 64;
+ ulong low = ~0UL;
+ ulong high = (highBits == 64)
+ ? ~0UL
+ : ((1UL << highBits) - 1UL);
+
+ return new UInt128(high, low);
+ }
+ }
+
+ ///
+ /// Reverse bits across the full 128-bit value
+ ///
+ public static UInt128 ReverseBits(this UInt128 value)
+ {
+ ulong revLow = ReverseBits(value.Low());
+ ulong revHigh = ReverseBits(value.High());
+
+ return new UInt128(revLow, revHigh);
+ }
+
+ private static ulong ReverseBits(ulong x)
+ {
+ x = ((x >> 1) & 0x5555555555555555UL) | ((x & 0x5555555555555555UL) << 1);
+ x = ((x >> 2) & 0x3333333333333333UL) | ((x & 0x3333333333333333UL) << 2);
+ x = ((x >> 4) & 0x0F0F0F0F0F0F0F0FUL) | ((x & 0x0F0F0F0F0F0F0F0FUL) << 4);
+ x = ((x >> 8) & 0x00FF00FF00FF00FFUL) | ((x & 0x00FF00FF00FF00FFUL) << 8);
+ x = ((x >> 16) & 0x0000FFFF0000FFFFUL) | ((x & 0x0000FFFF0000FFFFUL) << 16);
+ x = (x >> 32) | (x << 32);
+
+ return x;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/IO/AstcFile.cs b/src/ImageSharp.Textures/Compression/Astc/IO/AstcFile.cs
new file mode 100644
index 00000000..2388f24d
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/IO/AstcFile.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+///
+/// A very simple format consisting of a small header followed immediately
+/// by the binary payload for a single image surface.
+///
+///
+/// See https://github.com/ARM-software/astc-encoder/blob/main/Docs/FileFormat.md
+///
+internal record AstcFile
+{
+ private readonly AstcFileHeader header;
+ private readonly byte[] blocks;
+
+ internal AstcFile(AstcFileHeader header, byte[] blocks)
+ {
+ this.header = header;
+ this.blocks = blocks;
+ this.Footprint = this.GetFootprint();
+ }
+
+ public ReadOnlySpan Blocks => this.blocks;
+
+ public Footprint Footprint { get; }
+
+ public int Width => this.header.ImageWidth;
+
+ public int Height => this.header.ImageHeight;
+
+ public int Depth => this.header.ImageDepth;
+
+ public static AstcFile FromMemory(byte[] data)
+ {
+ AstcFileHeader header = AstcFileHeader.FromMemory(data.AsSpan(0, AstcFileHeader.SizeInBytes));
+
+ // Remaining bytes are blocks; C++ reference keeps them as string; here we keep as byte[]
+ byte[] blocks = new byte[data.Length - AstcFileHeader.SizeInBytes];
+ Array.Copy(data, AstcFileHeader.SizeInBytes, blocks, 0, blocks.Length);
+
+ return new AstcFile(header, blocks);
+ }
+
+ ///
+ /// Map the block dimensions in the header to a Footprint, if possible.
+ ///
+ private Footprint GetFootprint() => (this.header.BlockWidth, this.header.BlockHeight) switch
+ {
+ (4, 4) => Footprint.FromFootprintType(FootprintType.Footprint4x4),
+ (5, 4) => Footprint.FromFootprintType(FootprintType.Footprint5x4),
+ (5, 5) => Footprint.FromFootprintType(FootprintType.Footprint5x5),
+ (6, 5) => Footprint.FromFootprintType(FootprintType.Footprint6x5),
+ (6, 6) => Footprint.FromFootprintType(FootprintType.Footprint6x6),
+ (8, 5) => Footprint.FromFootprintType(FootprintType.Footprint8x5),
+ (8, 6) => Footprint.FromFootprintType(FootprintType.Footprint8x6),
+ (8, 8) => Footprint.FromFootprintType(FootprintType.Footprint8x8),
+ (10, 5) => Footprint.FromFootprintType(FootprintType.Footprint10x5),
+ (10, 6) => Footprint.FromFootprintType(FootprintType.Footprint10x6),
+ (10, 8) => Footprint.FromFootprintType(FootprintType.Footprint10x8),
+ (10, 10) => Footprint.FromFootprintType(FootprintType.Footprint10x10),
+ (12, 10) => Footprint.FromFootprintType(FootprintType.Footprint12x10),
+ (12, 12) => Footprint.FromFootprintType(FootprintType.Footprint12x12),
+ _ => throw new NotSupportedException($"Unsupported block dimensions: {this.header.BlockWidth}x{this.header.BlockHeight}"),
+ };
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/IO/AstcFileHeader.cs b/src/ImageSharp.Textures/Compression/Astc/IO/AstcFileHeader.cs
new file mode 100644
index 00000000..3963b5a3
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/IO/AstcFileHeader.cs
@@ -0,0 +1,43 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Buffers.Binary;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+///
+/// The 16 byte ASTC file header
+///
+///
+/// ASTC block and decoded image dimensions in texels.
+///
+/// For 2D images the Z dimension must be set to 1.
+///
+/// Note that the image is not required to be an exact multiple of the compressed block
+/// size; the compressed data may include padding that is discarded during decompression.
+///
+internal readonly record struct AstcFileHeader(byte BlockWidth, byte BlockHeight, byte BlockDepth, int ImageWidth, int ImageHeight, int ImageDepth)
+{
+ public const uint Magic = 0x5CA1AB13;
+ public const int SizeInBytes = 16;
+
+ public static AstcFileHeader FromMemory(Span data)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(data.Length, SizeInBytes);
+
+ // ASTC header is 16 bytes:
+ // - magic (4),
+ // - blockdim (3),
+ // - xsize,y,z (each 3 little-endian bytes)
+ uint magic = BinaryPrimitives.ReadUInt32LittleEndian(data);
+ ArgumentOutOfRangeException.ThrowIfNotEqual(magic, Magic);
+
+ return new AstcFileHeader(
+ BlockWidth: data[4],
+ BlockHeight: data[5],
+ BlockDepth: data[6],
+ ImageWidth: data[7] | (data[8] << 8) | (data[9] << 16),
+ ImageHeight: data[10] | (data[11] << 8) | (data[12] << 16),
+ ImageDepth: data[13] | (data[14] << 8) | (data[15] << 16));
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/IO/BitStream.cs b/src/ImageSharp.Textures/Compression/Astc/IO/BitStream.cs
new file mode 100644
index 00000000..a6e0791d
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/IO/BitStream.cs
@@ -0,0 +1,188 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Globalization;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+///
+/// A simple bit stream used for reading/writing arbitrary-sized chunks.
+///
+internal struct BitStream
+{
+ private ulong low;
+ private ulong high;
+ private uint dataSize; // number of valid bits in the 128-bit buffer
+
+ public BitStream(ulong data = 0, uint dataSize = 0)
+ {
+ this.low = data;
+ this.high = 0;
+ this.dataSize = dataSize;
+ }
+
+ public BitStream(UInt128 data, uint dataSize)
+ {
+ this.low = data.Low();
+ this.high = data.High();
+ this.dataSize = dataSize;
+ }
+
+ public readonly uint Bits => this.dataSize;
+
+ public void PutBits(T x, int size)
+ where T : unmanaged
+ {
+ ulong value = x switch
+ {
+ uint ui => ui,
+ ulong ul => ul,
+ ushort us => us,
+ byte b => b,
+ _ => Convert.ToUInt64(x, CultureInfo.InvariantCulture)
+ };
+
+ if (this.dataSize + (uint)size > 128)
+ {
+ throw new InvalidOperationException("Not enough space in BitStream");
+ }
+
+ if (this.dataSize < 64)
+ {
+ int lowFree = (int)(64 - this.dataSize);
+ if (size <= lowFree)
+ {
+ this.low |= (value & MaskFor(size)) << (int)this.dataSize;
+ }
+ else
+ {
+ this.low |= (value & MaskFor(lowFree)) << (int)this.dataSize;
+ this.high |= (value >> lowFree) & MaskFor(size - lowFree);
+ }
+ }
+ else
+ {
+ int shift = (int)(this.dataSize - 64);
+ this.high |= (value & MaskFor(size)) << shift;
+ }
+
+ this.dataSize += (uint)size;
+ }
+
+ ///
+ /// Attempt to retrieve the specified number of bits from the buffer.
+ /// The buffer is shifted accordingly if successful.
+ ///
+ public bool TryGetBits(int count, out T bits)
+ where T : unmanaged
+ {
+ T? result = null;
+
+ if (typeof(T) == typeof(UInt128))
+ {
+ result = (T?)(object?)this.GetBitsUInt128(count);
+ }
+ else if (count <= this.dataSize)
+ {
+ ulong value = count switch
+ {
+ 0 => 0,
+ <= 64 => this.low & MaskFor(count),
+ _ => this.low
+ };
+
+ this.ShiftBuffer(count);
+ object boxed = Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
+ result = (T)boxed;
+ }
+
+ bits = result ?? default;
+
+ return result is not null;
+ }
+
+ public bool TryGetBits(int count, out ulong bits)
+ {
+ if (count > this.dataSize)
+ {
+ bits = 0;
+ return false;
+ }
+
+ bits = count switch
+ {
+ 0 => 0,
+ <= 64 => this.low & MaskFor(count),
+ _ => this.low
+ };
+ this.ShiftBuffer(count);
+ return true;
+ }
+
+ public bool TryGetBits(int count, out uint bits)
+ {
+ if (count > this.dataSize)
+ {
+ bits = 0;
+ return false;
+ }
+
+ bits = (uint)(count switch
+ {
+ 0 => 0UL,
+ <= 64 => this.low & MaskFor(count),
+ _ => this.low
+ });
+ this.ShiftBuffer(count);
+ return true;
+ }
+
+ private static ulong MaskFor(int bits)
+ => bits == 64
+ ? ~0UL
+ : ((1UL << bits) - 1UL);
+
+ private UInt128? GetBitsUInt128(int count)
+ {
+ if (count > this.dataSize)
+ {
+ return null;
+ }
+
+ UInt128 result = count switch
+ {
+ 0 => UInt128.Zero,
+ <= 64 => (UInt128)(this.low & MaskFor(count)),
+ 128 => new UInt128(this.high, this.low),
+ _ => new UInt128(
+ (count - 64 == 64) ? this.high : (this.high & MaskFor(count - 64)),
+ this.low)
+ };
+
+ this.ShiftBuffer(count);
+
+ return result;
+ }
+
+ private void ShiftBuffer(int count)
+ {
+ if (count < 64)
+ {
+ this.low = (this.low >> count) | (this.high << (64 - count));
+ this.high >>= count;
+ }
+ else if (count == 64)
+ {
+ this.low = this.high;
+ this.high = 0;
+ }
+ else
+ {
+ this.low = this.high >> (count - 64);
+ this.high = 0;
+ }
+
+ this.dataSize -= (uint)count;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/README.md b/src/ImageSharp.Textures/Compression/Astc/README.md
new file mode 100644
index 00000000..932867fd
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/README.md
@@ -0,0 +1,44 @@
+Decoder for ASTC (Adaptive Scalable Texture Compression) textures, supporting both LDR and HDR content.
+
+Originally developed as the standalone [AstcSharp](https://github.com/Erik-White/AstcSharp) library.
+
+## Background
+
+ASTC is a lossy block-based texture compression format developed by ARM and standardized by Khronos. It was designed as a single format to replace the patchwork of earlier GPU compression schemes (ETC, S3TC/DXT, PVRTC) that were each tied to specific hardware vendors or pixel formats.
+
+### Key characteristics
+
+- **Fixed block size** — Every compressed block is 128 bits (16 bytes), regardless of the footprint. This gives a constant memory bandwidth cost per block fetch on the GPU.
+- **Variable footprint** — The block footprint ranges from 4x4 to 12x12 texels, giving bit rates from 8 bpp down to 0.89 bpp. Smaller footprints preserve more detail; larger footprints achieve higher compression.
+- **LDR and HDR support** — The same format handles both standard 8-bit and high dynamic range content. HDR blocks use a different endpoint encoding that stores values as UNORM16 instead of UNORM8.
+- **Partitions** — A single block can contain up to four partitions, each with its own pair of color endpoints. The decoder selects between partition layouts using a seed-based hash function.
+- **Dual plane** — Blocks can optionally use a second set of interpolation weights for one color channel, improving quality for textures where one channel varies independently (e.g. alpha or normal maps).
+- **Bounded Integer Sequence Encoding (BISE)** — Weights and color endpoint values are packed using a mixed radix encoding that combines bits with trits (base-3) or quints (base-5) to fill the 128-bit budget more efficiently than pure binary encoding.
+
+### Block decoding overview
+
+Decoding a single ASTC block involves:
+
+1. Reading the block mode to determine the weight grid dimensions, quantization level, and whether the block is void-extent or standard
+2. Unpacking the BISE-encoded interpolation weights and upsampling them to the texel grid
+3. Decoding the color endpoints for each partition using the endpoint encoding mode (luminance, RGB, RGBA, or HDR variants)
+4. For each texel, looking up its partition assignment, then interpolating between the two endpoints using the weight value
+
+## Features
+
+- Decode ASTC textures to RGBA32 (LDR) or RGBA float (HDR)
+- All 2D block footprints from 4x4 to 12x12
+
+## Decoding paths
+
+The decoder employs three block decoding strategies:
+
+1. **Direct decode** — Standard approach for normal blocks using batch unquantization without intermediate allocations
+2. **Fused decode** — Accelerated path for single-partition, single-plane LDR blocks with combined decoding and interpolation
+3. **Void extent** — Handles constant-color blocks
+
+## Useful links
+
+- [ASTC specification (Khronos Data Format Specification)](https://registry.khronos.org/DataFormat/specs/1.3/dataformat.1.3.html#ASTC)
+- [ARM ASTC Encoder](https://github.com/ARM-software/astc-encoder)
+- [Google astc-codec](https://github.com/google/astc-codec)
diff --git a/src/ImageSharp.Textures/Compression/Astc/TexelBlock/BlockInfo.cs b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/BlockInfo.cs
new file mode 100644
index 00000000..2468b776
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/BlockInfo.cs
@@ -0,0 +1,363 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System.Runtime.CompilerServices;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+///
+/// Fused block info computed in a single pass from raw ASTC block bits
+///
+internal struct BlockInfo
+{
+ private static readonly int[] WeightRanges =
+ [-1, -1, 1, 2, 3, 4, 5, 7, -1, -1, 9, 11, 15, 19, 23, 31];
+
+ private static readonly int[] ExtraCemBitsForPartition = [0, 2, 5, 8];
+
+ // Valid BISE endpoint ranges in descending order (only these produce valid encodings)
+ private static readonly int[] ValidEndpointRanges =
+ [255, 191, 159, 127, 95, 79, 63, 47, 39, 31, 23, 19, 15, 11, 9, 7, 5];
+
+ public bool IsValid;
+ public bool IsVoidExtent;
+
+ // Weight grid
+ public int GridWidth;
+ public int GridHeight;
+ public int WeightRange;
+ public int WeightBitCount;
+
+ // Partitions
+ public int PartitionCount;
+
+ // Dual plane
+ public bool IsDualPlane;
+ public int DualPlaneChannel; // only valid if IsDualPlane
+
+ // Color endpoints
+ public int ColorStartBit;
+ public int ColorBitCount;
+ public int ColorValuesRange;
+ public int ColorValuesCount;
+
+ // Endpoint modes (up to 4 partitions)
+ public ColorEndpointMode EndpointMode0;
+ public ColorEndpointMode EndpointMode1;
+ public ColorEndpointMode EndpointMode2;
+ public ColorEndpointMode EndpointMode3;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public readonly ColorEndpointMode GetEndpointMode(int partition) => partition switch
+ {
+ 0 => this.EndpointMode0,
+ 1 => this.EndpointMode1,
+ 2 => this.EndpointMode2,
+ 3 => this.EndpointMode3,
+ _ => this.EndpointMode0
+ };
+
+ ///
+ /// Decode all block info from raw 128-bit ASTC block data in a single pass.
+ /// Returns a BlockInfo with IsValid=false if the block is illegal or reserved.
+ /// Returns a BlockInfo with IsVoidExtent=true for void extent blocks.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveOptimization)]
+ public static BlockInfo Decode(UInt128 bits)
+ {
+ ulong lowBits = bits.Low();
+
+ // ---- Step 1: Check void extent ----
+ // Void extent: bits[0:9] == 0x1FC (9 bits)
+ if ((lowBits & 0x1FF) == 0x1FC)
+ {
+ return new BlockInfo
+ {
+ IsVoidExtent = true,
+ IsValid = !CheckVoidExtentIsIllegal(bits, lowBits)
+ };
+ }
+
+ // ---- Step 2: Decode block mode, grid dims, weight range in ONE pass ----
+ // This inlines DecodeBlockMode + DecodeWeightProperties
+ int gridWidth, gridHeight;
+ bool isWidthA6HeightB6 = false;
+ uint rBits; // 3-bit range index component
+
+ // bits[0:2] != 0
+ if ((lowBits & 0x3) != 0)
+ {
+ ulong modeBits = (lowBits >> 2) & 0x3; // bits[2:4]
+ int a = (int)((lowBits >> 5) & 0x3); // bits[5:7]
+
+ (gridWidth, gridHeight) = modeBits switch
+ {
+ 0 => ((int)((lowBits >> 7) & 0x3) + 4, a + 2),
+ 1 => ((int)((lowBits >> 7) & 0x3) + 8, a + 2),
+ 2 => (a + 2, (int)((lowBits >> 7) & 0x3) + 8),
+ 3 when ((lowBits >> 8) & 1) != 0 => ((int)((lowBits >> 7) & 0x1) + 2, a + 2),
+ 3 => (a + 2, (int)((lowBits >> 7) & 0x1) + 6),
+ _ => default // unreachable
+ };
+
+ // Range r[2:0] = {bit4, bit1, bit0} for these modes
+ rBits = (uint)(((lowBits >> 4) & 1) | (((lowBits >> 0) & 0x3) << 1));
+ }
+ else
+ {
+ // bits[0:2] == 0
+ ulong modeBits = (lowBits >> 5) & 0xF; // bits[5:9]
+ int a = (int)((lowBits >> 5) & 0x3); // bits[5:7]
+
+ switch (modeBits)
+ {
+ case var _ when (modeBits & 0xC) == 0x0:
+ if ((lowBits & 0xF) == 0)
+ {
+ return default; // reserved block mode
+ }
+
+ gridWidth = 12;
+ gridHeight = a + 2;
+ break;
+ case var _ when (modeBits & 0xC) == 0x4:
+ gridWidth = a + 2;
+ gridHeight = 12;
+ break;
+ case 0xC:
+ gridWidth = 6;
+ gridHeight = 10;
+ break;
+ case 0xD:
+ gridWidth = 10;
+ gridHeight = 6;
+ break;
+ case var _ when (modeBits & 0xC) == 0x8:
+ gridWidth = a + 6;
+ gridHeight = (int)((lowBits >> 9) & 0x3) + 6;
+ isWidthA6HeightB6 = true;
+ break;
+ default:
+ return default; // reserved
+ }
+
+ // Range r[2:0] = {bit4, bit3, bit2} for these modes
+ rBits = (uint)(((lowBits >> 4) & 1) | (((lowBits >> 2) & 0x3) << 1));
+ }
+
+ // ---- Step 3: Compute weight range from r and h bits ----
+ uint hBit = isWidthA6HeightB6
+ ? 0u
+ : (uint)((lowBits >> 9) & 1);
+ int rangeIdx = (int)((hBit << 3) | rBits);
+ if ((uint)rangeIdx >= (uint)WeightRanges.Length)
+ {
+ return default;
+ }
+
+ int weightRange = WeightRanges[rangeIdx];
+ if (weightRange < 0)
+ {
+ return default;
+ }
+
+ // ---- Step 4: Dual plane ----
+ // WidthA6HeightB6 mode never has dual plane; otherwise check bit 10
+ bool isDualPlane = !isWidthA6HeightB6 && ((lowBits >> 10) & 1) != 0;
+
+ // ---- Step 5: Partition count ----
+ int partitionCount = 1 + (int)((lowBits >> 11) & 0x3);
+
+ // ---- Step 6: Validate weight count ----
+ int numWeights = gridWidth * gridHeight;
+ if (isDualPlane)
+ {
+ numWeights *= 2;
+ }
+
+ if (numWeights > 64)
+ {
+ return default;
+ }
+
+ // 4 partitions + dual plane is illegal
+ if (partitionCount == 4 && isDualPlane)
+ {
+ return default;
+ }
+
+ // ---- Step 7: Weight bit count ----
+ int weightBitCount = BoundedIntegerSequenceCodec.GetBitCountForRange(numWeights, weightRange);
+ if (weightBitCount is < 24 or > 96)
+ {
+ return default;
+ }
+
+ // ---- Step 8: Endpoint modes + extra CEM bits ----
+ ColorEndpointMode cem0 = default, cem1 = default, cem2 = default, cem3 = default;
+ int colorValuesCount = 0;
+ int numExtraCEMBits = 0;
+
+ if (partitionCount == 1)
+ {
+ cem0 = (ColorEndpointMode)((lowBits >> 13) & 0xF);
+ colorValuesCount = (((int)cem0 / 4) + 1) * 2;
+ }
+ else
+ {
+ // Multi-partition CEM decode
+ ulong sharedCemMarker = (lowBits >> 23) & 0x3;
+
+ if (sharedCemMarker == 0)
+ {
+ // Shared CEM: all partitions use the same mode
+ ColorEndpointMode sharedCem = (ColorEndpointMode)((lowBits >> 25) & 0xF);
+ cem0 = cem1 = cem2 = cem3 = sharedCem;
+ for (int i = 0; i < partitionCount; i++)
+ {
+ colorValuesCount += sharedCem.GetColorValuesCount();
+ }
+ }
+ else
+ {
+ // Non-shared CEM: per-partition modes
+ numExtraCEMBits = ExtraCemBitsForPartition[partitionCount - 1];
+
+ int extraCemStartPos = 128 - numExtraCEMBits - weightBitCount;
+ UInt128 extraCem = BitOperations.GetBits(bits, extraCemStartPos, numExtraCEMBits);
+
+ ulong cemval = (lowBits >> 23) & 0x3F; // 6 bits starting at bit 23
+ int baseCem = (int)(((cemval & 0x3) - 1) * 4);
+ cemval >>= 2;
+
+ ulong combined = cemval | (extraCem.Low() << 4);
+ ulong cembits = combined;
+
+ // Extract c bits (1 bit per partition)
+ Span c = stackalloc int[4];
+ for (int i = 0; i < partitionCount; i++)
+ {
+ c[i] = (int)(cembits & 0x1);
+ cembits >>= 1;
+ }
+
+ // Extract m bits (2 bits per partition)
+ for (int i = 0; i < partitionCount; i++)
+ {
+ int m = (int)(cembits & 0x3);
+ cembits >>= 2;
+ ColorEndpointMode mode = (ColorEndpointMode)(baseCem + (4 * c[i]) + m);
+ switch (i)
+ {
+ case 0:
+ cem0 = mode;
+ break;
+ case 1:
+ cem1 = mode;
+ break;
+ case 2:
+ cem2 = mode;
+ break;
+ case 3:
+ cem3 = mode;
+ break;
+ }
+
+ colorValuesCount += mode.GetColorValuesCount();
+ }
+ }
+ }
+
+ if (colorValuesCount > 18)
+ {
+ return default;
+ }
+
+ // ---- Step 9: Dual plane start position and channel ----
+ int dualPlaneBitStartPos = 128 - weightBitCount - numExtraCEMBits;
+ if (isDualPlane)
+ {
+ dualPlaneBitStartPos -= 2;
+ }
+
+ int dualPlaneChannel = isDualPlane
+ ? (int)BitOperations.GetBits(bits, dualPlaneBitStartPos, 2).Low()
+ : -1;
+
+ // ---- Step 10: Color values info ----
+ int colorStartBit = (partitionCount == 1) ? 17 : 29;
+ int maxColorBits = dualPlaneBitStartPos - colorStartBit;
+
+ // Minimum bits needed check
+ int requiredColorBits = ((13 * colorValuesCount) + 4) / 5;
+ if (maxColorBits < requiredColorBits)
+ {
+ return default;
+ }
+
+ // Find max color range that fits (only check valid BISE ranges: 17 vs up to 255)
+ int colorValuesRange = 0, colorBitCount = 0;
+ foreach (int rv in ValidEndpointRanges)
+ {
+ int bitCount = BoundedIntegerSequenceCodec.GetBitCountForRange(colorValuesCount, rv);
+ if (bitCount <= maxColorBits)
+ {
+ colorValuesRange = rv;
+ colorBitCount = bitCount;
+ break;
+ }
+ }
+
+ if (colorValuesRange == 0)
+ {
+ return default;
+ }
+
+ // ---- Step 11: Validate endpoint modes are not HDR for batchable checks ----
+ // (HDR blocks are still valid, just flagged for downstream use)
+ return new BlockInfo
+ {
+ IsValid = true,
+ IsVoidExtent = false,
+ GridWidth = gridWidth,
+ GridHeight = gridHeight,
+ WeightRange = weightRange,
+ WeightBitCount = weightBitCount,
+ PartitionCount = partitionCount,
+ IsDualPlane = isDualPlane,
+ DualPlaneChannel = dualPlaneChannel,
+ ColorStartBit = colorStartBit,
+ ColorBitCount = colorBitCount,
+ ColorValuesRange = colorValuesRange,
+ ColorValuesCount = colorValuesCount,
+ EndpointMode0 = cem0,
+ EndpointMode1 = cem1,
+ EndpointMode2 = cem2,
+ EndpointMode3 = cem3,
+ };
+ }
+
+ ///
+ /// Inline void extent validation (replaces PhysicalBlock.CheckVoidExtentIsIllegal).
+ ///
+ private static bool CheckVoidExtentIsIllegal(UInt128 bits, ulong lowBits)
+ {
+ if (BitOperations.GetBits(bits, 10, 2).Low() != 0x3UL)
+ {
+ return true;
+ }
+
+ int c0 = (int)BitOperations.GetBits(lowBits, 12, 13);
+ int c1 = (int)BitOperations.GetBits(lowBits, 25, 13);
+ int c2 = (int)BitOperations.GetBits(lowBits, 38, 13);
+ int c3 = (int)BitOperations.GetBits(lowBits, 51, 13);
+
+ const int all1s = (1 << 13) - 1;
+ bool coordsAll1s = c0 == all1s && c1 == all1s && c2 == all1s && c3 == all1s;
+
+ return !coordsAll1s && (c0 >= c1 || c2 >= c3);
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/TexelBlock/IntermediateBlock.cs b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/IntermediateBlock.cs
new file mode 100644
index 00000000..2db99291
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/IntermediateBlock.cs
@@ -0,0 +1,284 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+internal static class IntermediateBlock
+{
+ // From Table C.2.7 -- valid weight ranges
+ public static readonly int[] ValidWeightRanges = [1, 2, 3, 4, 5, 7, 9, 11, 15, 19, 23, 31];
+
+ // Returns the maximum endpoint value range or negative on error
+ private const int EndpointRangeInvalidWeightDimensions = -1;
+ private const int EndpointRangeNotEnoughColorBits = -2;
+
+ public static IntermediateBlockData? UnpackIntermediateBlock(PhysicalBlock physicalBlock)
+ {
+ if (physicalBlock.IsIllegalEncoding || physicalBlock.IsVoidExtent)
+ {
+ return null;
+ }
+
+ BlockInfo info = BlockInfo.Decode(physicalBlock.BlockBits);
+ if (!info.IsValid || info.IsVoidExtent)
+ {
+ return null;
+ }
+
+ return UnpackIntermediateBlock(physicalBlock.BlockBits, in info);
+ }
+
+ ///
+ /// Fast overload that uses pre-computed BlockInfo instead of calling PhysicalBlock getters.
+ ///
+ public static IntermediateBlockData? UnpackIntermediateBlock(UInt128 bits, in BlockInfo info)
+ {
+ if (!info.IsValid || info.IsVoidExtent)
+ {
+ return null;
+ }
+
+ IntermediateBlockData data = default;
+
+ // Use cached values from BlockInfo instead of PhysicalBlock getters
+ UInt128 colorBitMask = UInt128Extensions.OnesMask(info.ColorBitCount);
+ UInt128 colorBits = (bits >> info.ColorStartBit) & colorBitMask;
+ BitStream colorBitStream = new(colorBits, 128);
+
+ BoundedIntegerSequenceDecoder colorDecoder = BoundedIntegerSequenceDecoder.GetCached(info.ColorValuesRange);
+ Span colors = stackalloc int[info.ColorValuesCount];
+ colorDecoder.Decode(info.ColorValuesCount, ref colorBitStream, colors);
+
+ data.WeightGridX = info.GridWidth;
+ data.WeightGridY = info.GridHeight;
+ data.WeightRange = info.WeightRange;
+
+ data.PartitionId = info.PartitionCount > 1
+ ? (int)BitOperations.GetBits(bits.Low(), 13, 10)
+ : null;
+
+ data.DualPlaneChannel = info.IsDualPlane
+ ? info.DualPlaneChannel
+ : null;
+
+ int colorIndex = 0;
+ data.EndpointCount = info.PartitionCount;
+ for (int i = 0; i < info.PartitionCount; ++i)
+ {
+ ColorEndpointMode mode = info.GetEndpointMode(i);
+ int colorCount = mode.GetColorValuesCount();
+ IntermediateEndpointData ep = new()
+ { Mode = mode, ColorCount = colorCount };
+ for (int j = 0; j < colorCount; ++j)
+ {
+ ep.Colors[j] = colors[colorIndex++];
+ }
+
+ data.Endpoints[i] = ep;
+ }
+
+ data.EndpointRange = info.ColorValuesRange;
+
+ UInt128 weightBits = UInt128Extensions.ReverseBits(bits) & UInt128Extensions.OnesMask(info.WeightBitCount);
+ BitStream weightBitStream = new(weightBits, 128);
+
+ BoundedIntegerSequenceDecoder weightDecoder = BoundedIntegerSequenceDecoder.GetCached(data.WeightRange);
+ int weightsCount = data.WeightGridX * data.WeightGridY;
+ if (info.IsDualPlane)
+ {
+ weightsCount *= 2;
+ }
+
+ data.Weights = new int[weightsCount];
+ data.WeightsCount = weightsCount;
+ weightDecoder.Decode(weightsCount, ref weightBitStream, data.Weights);
+
+ return data;
+ }
+
+ public static int EndpointRangeForBlock(in IntermediateBlockData data)
+ {
+ int dualPlaneMultiplier = data.DualPlaneChannel.HasValue
+ ? 2
+ : 1;
+ if (BoundedIntegerSequenceCodec.GetBitCountForRange(data.WeightGridX * data.WeightGridY * dualPlaneMultiplier, data.WeightRange) > 96)
+ {
+ return EndpointRangeInvalidWeightDimensions;
+ }
+
+ int partitionCount = data.EndpointCount;
+ int bitsWrittenCount = 11 + 2
+ + ((partitionCount > 1) ? 10 : 0)
+ + ((partitionCount == 1) ? 4 : 6);
+ int availableColorBitsCount = ExtraConfigBitPosition(data) - bitsWrittenCount;
+
+ int colorValuesCount = 0;
+ for (int i = 0; i < data.EndpointCount; i++)
+ {
+ colorValuesCount += data.Endpoints[i].Mode.GetColorValuesCount();
+ }
+
+ int bitsNeededCount = ((13 * colorValuesCount) + 4) / 5;
+ if (availableColorBitsCount < bitsNeededCount)
+ {
+ return EndpointRangeNotEnoughColorBits;
+ }
+
+ int colorValueRange = byte.MaxValue;
+ for (; colorValueRange > 1; --colorValueRange)
+ {
+ int bitCountForRange = BoundedIntegerSequenceCodec.GetBitCountForRange(colorValuesCount, colorValueRange);
+ if (bitCountForRange <= availableColorBitsCount)
+ {
+ break;
+ }
+ }
+
+ return colorValueRange;
+ }
+
+ public static VoidExtentData? UnpackVoidExtent(PhysicalBlock physicalBlock)
+ {
+ int? colorStartBit = physicalBlock.GetColorStartBit();
+ int? colorBitCount = physicalBlock.GetColorBitCount();
+ if (physicalBlock.IsIllegalEncoding || !physicalBlock.IsVoidExtent || colorStartBit is null || colorBitCount is null)
+ {
+ return null;
+ }
+
+ UInt128 colorBits = (physicalBlock.BlockBits >> colorStartBit.Value) & UInt128Extensions.OnesMask(colorBitCount.Value);
+
+ // We expect low 64 bits contain the 4x16-bit channels
+ ulong low = colorBits.Low();
+
+ VoidExtentData data = default;
+
+ // Bit 9 of the block mode indicates HDR (1) vs LDR (0) void extent
+ data.IsHdr = (physicalBlock.BlockBits.Low() & (1UL << 9)) != 0;
+ data.R = (ushort)((low >> 0) & 0xFFFF);
+ data.G = (ushort)((low >> 16) & 0xFFFF);
+ data.B = (ushort)((low >> 32) & 0xFFFF);
+ data.A = (ushort)((low >> 48) & 0xFFFF);
+
+ int[]? coords = physicalBlock.GetVoidExtentCoordinates();
+ data.Coords = new ushort[4];
+ if (coords != null)
+ {
+ data.Coords[0] = (ushort)coords[0];
+ data.Coords[1] = (ushort)coords[1];
+ data.Coords[2] = (ushort)coords[2];
+ data.Coords[3] = (ushort)coords[3];
+ }
+ else
+ {
+ ushort allOnes = (1 << 13) - 1;
+ for (int i = 0; i < 4; ++i)
+ {
+ data.Coords[i] = allOnes;
+ }
+ }
+
+ return data;
+ }
+
+ ///
+ /// Determines if all endpoint modes in the intermediate block data are the same
+ ///
+ internal static bool SharedEndpointModes(in IntermediateBlockData data)
+ {
+ if (data.EndpointCount == 0)
+ {
+ return true;
+ }
+
+ ColorEndpointMode first = data.Endpoints[0].Mode;
+ for (int i = 1; i < data.EndpointCount; i++)
+ {
+ if (data.Endpoints[i].Mode != first)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ internal static int ExtraConfigBitPosition(in IntermediateBlockData data)
+ {
+ bool hasDualChannel = data.DualPlaneChannel.HasValue;
+ int weightCount = data.WeightGridX * data.WeightGridY * (hasDualChannel ? 2 : 1);
+ int weightBitCount = BoundedIntegerSequenceCodec.GetBitCountForRange(weightCount, data.WeightRange);
+
+ int extraConfigBitCount = 0;
+ if (!SharedEndpointModes(data))
+ {
+ int encodedCemBitCount = 2 + (data.EndpointCount * 3);
+ extraConfigBitCount = encodedCemBitCount - 6;
+ }
+
+ if (hasDualChannel)
+ {
+ extraConfigBitCount += 2;
+ }
+
+ return 128 - weightBitCount - extraConfigBitCount;
+ }
+
+ internal struct VoidExtentData
+ {
+ public bool IsHdr;
+ public ushort R;
+ public ushort G;
+ public ushort B;
+ public ushort A;
+ public ushort[] Coords; // length 4
+ }
+
+ [System.Runtime.CompilerServices.InlineArray(MaxColorValues)]
+ internal struct EndpointColorValues
+ {
+ public const int MaxColorValues = 8;
+#pragma warning disable CS0169, S1144 // Accessed by runtime via [InlineArray]
+ private int element0;
+#pragma warning restore CS0169, S1144
+ }
+
+ internal struct IntermediateBlockData
+ {
+ public int WeightGridX;
+ public int WeightGridY;
+ public int WeightRange;
+
+ public int[] Weights;
+ public int WeightsCount;
+
+ public int? PartitionId;
+ public int? DualPlaneChannel;
+
+ public IntermediateEndpointBuffer Endpoints;
+ public int EndpointCount;
+
+ public int? EndpointRange;
+ }
+
+ internal struct IntermediateEndpointData
+ {
+ public ColorEndpointMode Mode;
+ public EndpointColorValues Colors;
+ public int ColorCount;
+ }
+
+ [System.Runtime.CompilerServices.InlineArray(MaxPartitions)]
+ internal struct IntermediateEndpointBuffer
+ {
+ public const int MaxPartitions = 4;
+#pragma warning disable CS0169, S1144 // Accessed by runtime via [InlineArray]
+ private IntermediateEndpointData element0;
+#pragma warning restore CS0169, S1144
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/TexelBlock/IntermediateBlockPacker.cs b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/IntermediateBlockPacker.cs
new file mode 100644
index 00000000..aa0e37f6
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/IntermediateBlockPacker.cs
@@ -0,0 +1,403 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using SixLabors.ImageSharp.Textures.Compression.Astc.IO;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+internal static class IntermediateBlockPacker
+{
+ private static readonly BlockModeInfo[] BlockModeInfoTable = [
+ new BlockModeInfo { MinWeightGridDimX = 4, MaxWeightGridDimX = 7, MinWeightGridDimY = 2, MaxWeightGridDimY = 5, R0BitPos = 4, R1BitPos = 0, R2BitPos = 1, WeightGridXOffsetBitPos = 7, WeightGridYOffsetBitPos = 5, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 8, MaxWeightGridDimX = 11, MinWeightGridDimY = 2, MaxWeightGridDimY = 5, R0BitPos = 4, R1BitPos = 0, R2BitPos = 1, WeightGridXOffsetBitPos = 7, WeightGridYOffsetBitPos = 5, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 2, MaxWeightGridDimX = 5, MinWeightGridDimY = 8, MaxWeightGridDimY = 11, R0BitPos = 4, R1BitPos = 0, R2BitPos = 1, WeightGridXOffsetBitPos = 5, WeightGridYOffsetBitPos = 7, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 2, MaxWeightGridDimX = 5, MinWeightGridDimY = 6, MaxWeightGridDimY = 7, R0BitPos = 4, R1BitPos = 0, R2BitPos = 1, WeightGridXOffsetBitPos = 5, WeightGridYOffsetBitPos = 7, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 2, MaxWeightGridDimX = 3, MinWeightGridDimY = 2, MaxWeightGridDimY = 5, R0BitPos = 4, R1BitPos = 0, R2BitPos = 1, WeightGridXOffsetBitPos = 7, WeightGridYOffsetBitPos = 5, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 12, MaxWeightGridDimX = 12, MinWeightGridDimY = 2, MaxWeightGridDimY = 5, R0BitPos = 4, R1BitPos = 2, R2BitPos = 3, WeightGridXOffsetBitPos = -1, WeightGridYOffsetBitPos = 5, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 2, MaxWeightGridDimX = 5, MinWeightGridDimY = 12, MaxWeightGridDimY = 12, R0BitPos = 4, R1BitPos = 2, R2BitPos = 3, WeightGridXOffsetBitPos = 5, WeightGridYOffsetBitPos = -1, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 6, MaxWeightGridDimX = 6, MinWeightGridDimY = 10, MaxWeightGridDimY = 10, R0BitPos = 4, R1BitPos = 2, R2BitPos = 3, WeightGridXOffsetBitPos = -1, WeightGridYOffsetBitPos = -1, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 10, MaxWeightGridDimX = 10, MinWeightGridDimY = 6, MaxWeightGridDimY = 6, R0BitPos = 4, R1BitPos = 2, R2BitPos = 3, WeightGridXOffsetBitPos = -1, WeightGridYOffsetBitPos = -1, RequireSinglePlaneLowPrec = false },
+ new BlockModeInfo { MinWeightGridDimX = 6, MaxWeightGridDimX = 9, MinWeightGridDimY = 6, MaxWeightGridDimY = 9, R0BitPos = 4, R1BitPos = 2, R2BitPos = 3, WeightGridXOffsetBitPos = 5, WeightGridYOffsetBitPos = 9, RequireSinglePlaneLowPrec = true }
+ ];
+
+ private static readonly uint[] BlockModeMasks = [0x0u, 0x4u, 0x8u, 0xCu, 0x10Cu, 0x0u, 0x80u, 0x180u, 0x1A0u, 0x100u];
+
+ public static (string? Error, UInt128 PhysicalBlockBits) Pack(in IntermediateBlock.IntermediateBlockData data)
+ {
+ UInt128 physicalBlockBits = 0;
+ int expectedWeightsCount = data.WeightGridX * data.WeightGridY
+ * (data.DualPlaneChannel.HasValue ? 2 : 1);
+ int actualWeightsCount = data.WeightsCount > 0
+ ? data.WeightsCount
+ : (data.Weights?.Length ?? 0);
+ if (actualWeightsCount != expectedWeightsCount)
+ {
+ return ("Incorrect number of weights!", 0);
+ }
+
+ BitStream bitSink = new(0UL, 0);
+
+ // First we need to encode the block mode.
+ string? errorMessage = PackBlockMode(data.WeightGridX, data.WeightGridY, data.WeightRange, data.DualPlaneChannel.HasValue, ref bitSink);
+ if (errorMessage != null)
+ {
+ return (errorMessage, 0);
+ }
+
+ // number of partitions minus one
+ int partitionCount = data.EndpointCount;
+ bitSink.PutBits((uint)(partitionCount - 1), 2);
+
+ if (partitionCount > 1)
+ {
+ int id = data.PartitionId ?? 0;
+ ArgumentOutOfRangeException.ThrowIfLessThan(id, 0);
+ bitSink.PutBits((uint)id, 10);
+ }
+
+ (BitStream weightSink, int weightBitsCount) = EncodeWeights(data);
+
+ (string? error, int extraConfig) = EncodeColorEndpointModes(data, partitionCount, ref bitSink);
+ if (error != null)
+ {
+ return (error, 0);
+ }
+
+ int colorValueRange = data.EndpointRange ?? IntermediateBlock.EndpointRangeForBlock(data);
+ if (colorValueRange == -1)
+ {
+ throw new InvalidOperationException($"{nameof(colorValueRange)} must not be EndpointRangeInvalidWeightDimensions");
+ }
+
+ if (colorValueRange == -2)
+ {
+ return ("Intermediate block emits illegal color range", 0);
+ }
+
+ BoundedIntegerSequenceEncoder colorEncoder = new(colorValueRange);
+ for (int i = 0; i < data.EndpointCount; i++)
+ {
+ IntermediateBlock.IntermediateEndpointData ep = data.Endpoints[i];
+ for (int j = 0; j < ep.ColorCount; j++)
+ {
+ int color = ep.Colors[j];
+ if (color > colorValueRange)
+ {
+ return ("Color outside available color range!", 0);
+ }
+
+ colorEncoder.AddValue(color);
+ }
+ }
+
+ colorEncoder.Encode(ref bitSink);
+
+ int extraConfigBitPosition = IntermediateBlock.ExtraConfigBitPosition(data);
+ int extraConfigBits = 128 - weightBitsCount - extraConfigBitPosition;
+
+ ArgumentOutOfRangeException.ThrowIfNegative(extraConfigBits);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(extraConfig, 1 << extraConfigBits);
+
+ int bitsToSkip = extraConfigBitPosition - (int)bitSink.Bits;
+ ArgumentOutOfRangeException.ThrowIfNegative(bitsToSkip);
+ while (bitsToSkip > 0)
+ {
+ int skipping = Math.Min(32, bitsToSkip);
+ bitSink.PutBits(0u, skipping);
+ bitsToSkip -= skipping;
+ }
+
+ if (extraConfigBits > 0)
+ {
+ bitSink.PutBits((uint)extraConfig, extraConfigBits);
+ }
+
+ ArgumentOutOfRangeException.ThrowIfNotEqual(bitSink.Bits, 128u - weightBitsCount);
+
+ // Flush out the bit writer
+ if (!bitSink.TryGetBits(128 - weightBitsCount, out UInt128 astcBits))
+ {
+ throw new InvalidOperationException();
+ }
+
+ if (!weightSink.TryGetBits(weightBitsCount, out UInt128 revWeightBits))
+ {
+ throw new InvalidOperationException();
+ }
+
+ UInt128 combined = astcBits | UInt128Extensions.ReverseBits(revWeightBits);
+ physicalBlockBits = combined;
+
+ PhysicalBlock block = PhysicalBlock.Create(physicalBlockBits);
+ string? illegal = block.IdentifyInvalidEncodingIssues();
+
+ return (illegal, physicalBlockBits);
+ }
+
+ public static (string? Error, UInt128 PhysicalBlockBits) Pack(IntermediateBlock.VoidExtentData data)
+ {
+ // Pack void extent
+ // Assemble the 128-bit value explicitly: low 64 bits = RGBA (4x16)
+ // high 64 bits = 12-bit header (0xDFC) followed by four 13-bit coords.
+ ulong high64 = ((ulong)data.A << 48) | ((ulong)data.B << 32) | ((ulong)data.G << 16) | data.R;
+ ulong low64 = 0UL;
+
+ // Header occupies lowest 12 bits of the high word
+ low64 |= 0xDFCu;
+ for (int i = 0; i < 4; ++i)
+ {
+ low64 |= ((ulong)(data.Coords[i] & 0x1FFF)) << (12 + (13 * i));
+ }
+
+ UInt128 physicalBlockBits;
+
+ // Decide representation: if the RGBA low word is zero we emit the
+ // compact single-ulong representation (low word = header+coords,
+ // high word = 0) to match the reference tests. Otherwise the
+ // low word holds RGBA and the high word holds header+coords.
+ if (high64 == 0UL)
+ {
+ physicalBlockBits = (UInt128)low64;
+
+ // using compact void extent representation
+ }
+ else
+ {
+ physicalBlockBits = new UInt128(high64, low64);
+
+ // using full void extent representation
+ }
+
+ PhysicalBlock block = PhysicalBlock.Create(physicalBlockBits);
+ string? illegal = block.IdentifyInvalidEncodingIssues();
+ if (illegal is not null)
+ {
+ throw new InvalidOperationException($"{nameof(Pack)}(void extent) produced illegal encoding");
+ }
+
+ return (illegal, physicalBlockBits);
+ }
+
+ private static (string? Error, int[] Range) GetEncodedWeightRange(int range)
+ {
+ int[][] validRangeEncodings = [
+ [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1],
+ [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1]
+ ];
+
+ int smallestRange = IntermediateBlock.ValidWeightRanges.First();
+ int largestRange = IntermediateBlock.ValidWeightRanges.Last();
+ if (range < smallestRange || largestRange < range)
+ {
+ return ($"Could not find block mode. Invalid weight range: {range} not in [{smallestRange}, {largestRange}]", new int[3]);
+ }
+
+ int index = Array.FindIndex(IntermediateBlock.ValidWeightRanges, v => v >= range);
+ if (index < 0)
+ {
+ index = IntermediateBlock.ValidWeightRanges.Length - 1;
+ }
+
+ int[] encoding = validRangeEncodings[index];
+ return (null, [encoding[0], encoding[1], encoding[2]]);
+ }
+
+ private static string? PackBlockMode(int dimX, int dimY, int range, bool dualPlane, ref BitStream bitSink)
+ {
+ bool highPrec = range > 7;
+ (string? maybeErr, int[]? rangeValues) = GetEncodedWeightRange(range);
+ if (maybeErr != null)
+ {
+ return maybeErr;
+ }
+
+ // Ensure top two bits of r1 and r2 not both zero per reference
+ if ((rangeValues[1] | rangeValues[2]) <= 0)
+ {
+ throw new InvalidOperationException($"{nameof(rangeValues)}[1] | {nameof(rangeValues)}[2] must be > 0");
+ }
+
+ for (int mode = 0; mode < BlockModeInfoTable.Length; ++mode)
+ {
+ BlockModeInfo blockMode = BlockModeInfoTable[mode];
+ bool isValidMode = true;
+ isValidMode &= blockMode.MinWeightGridDimX <= dimX;
+ isValidMode &= dimX <= blockMode.MaxWeightGridDimX;
+ isValidMode &= blockMode.MinWeightGridDimY <= dimY;
+ isValidMode &= dimY <= blockMode.MaxWeightGridDimY;
+ isValidMode &= !(blockMode.RequireSinglePlaneLowPrec && dualPlane);
+ isValidMode &= !(blockMode.RequireSinglePlaneLowPrec && highPrec);
+
+ if (!isValidMode)
+ {
+ continue;
+ }
+
+ uint encodedMode = BlockModeMasks[mode];
+ void SetBit(uint value, int offset)
+ {
+ if (offset < 0)
+ {
+ return;
+ }
+
+ encodedMode = (encodedMode & ~(1u << offset)) | ((value & 1u) << offset);
+ }
+
+ SetBit((uint)rangeValues[0], blockMode.R0BitPos);
+ SetBit((uint)rangeValues[1], blockMode.R1BitPos);
+ SetBit((uint)rangeValues[2], blockMode.R2BitPos);
+
+ int offsetX = dimX - blockMode.MinWeightGridDimX;
+ int offsetY = dimY - blockMode.MinWeightGridDimY;
+
+ if (blockMode.WeightGridXOffsetBitPos >= 0)
+ {
+ encodedMode |= (uint)(offsetX << blockMode.WeightGridXOffsetBitPos);
+ }
+ else
+ {
+ ArgumentOutOfRangeException.ThrowIfNotEqual(offsetX, 0);
+ }
+
+ if (blockMode.WeightGridYOffsetBitPos >= 0)
+ {
+ encodedMode |= (uint)(offsetY << blockMode.WeightGridYOffsetBitPos);
+ }
+ else
+ {
+ ArgumentOutOfRangeException.ThrowIfNotEqual(offsetY, 0);
+ }
+
+ if (!blockMode.RequireSinglePlaneLowPrec)
+ {
+ SetBit(highPrec ? 1u : 0u, 9);
+ SetBit(dualPlane ? 1u : 0u, 10);
+ }
+
+ if (bitSink.Bits != 0)
+ {
+ throw new InvalidOperationException($"{nameof(bitSink)}.{nameof(bitSink.Bits)} must be 0");
+ }
+
+ bitSink.PutBits(encodedMode, 11);
+ return null;
+ }
+
+ return "Could not find viable block mode";
+ }
+
+ private static (BitStream WeightSink, int WeightBitsCount) EncodeWeights(in IntermediateBlock.IntermediateBlockData data)
+ {
+ BitStream weightSink = new(0UL, 0);
+ BoundedIntegerSequenceEncoder weightsEncoder = new(data.WeightRange);
+ int weightCount = data.WeightsCount > 0
+ ? data.WeightsCount
+ : (data.Weights?.Length ?? 0);
+ if (data.Weights is null)
+ {
+ throw new InvalidOperationException($"{nameof(data.Weights)} is null in {nameof(EncodeWeights)}");
+ }
+
+ for (int i = 0; i < weightCount; i++)
+ {
+ weightsEncoder.AddValue(data.Weights[i]);
+ }
+
+ weightsEncoder.Encode(ref weightSink);
+
+ int weightBitsCount = (int)weightSink.Bits;
+ if ((int)weightSink.Bits != BoundedIntegerSequenceCodec.GetBitCountForRange(weightCount, data.WeightRange))
+ {
+ throw new InvalidOperationException($"{nameof(weightSink)}.{nameof(weightSink.Bits)} does not match expected bit count");
+ }
+
+ return (weightSink, weightBitsCount);
+ }
+
+ private static (string? Error, int ExtraConfig) EncodeColorEndpointModes(in IntermediateBlock.IntermediateBlockData data, int partitionCount, ref BitStream bitSink)
+ {
+ int extraConfig = 0;
+ bool sharedEndpointMode = IntermediateBlock.SharedEndpointModes(data);
+
+ if (sharedEndpointMode)
+ {
+ if (partitionCount > 1)
+ {
+ bitSink.PutBits(0u, 2);
+ }
+
+ bitSink.PutBits((uint)data.Endpoints[0].Mode, 4);
+ }
+ else
+ {
+ // compute min_class, max_class
+ int minClass = 2;
+ int maxClass = 0;
+ for (int i = 0; i < data.EndpointCount; i++)
+ {
+ int endpointModeClass = ((int)data.Endpoints[i].Mode) >> 2;
+ minClass = Math.Min(minClass, endpointModeClass);
+ maxClass = Math.Max(maxClass, endpointModeClass);
+ }
+
+ if (maxClass - minClass > 1)
+ {
+ return ("Endpoint modes are invalid", 0);
+ }
+
+ BitStream cemEncoder = new(0UL, 0);
+ cemEncoder.PutBits((uint)(minClass + 1), 2);
+
+ for (int i = 0; i < data.EndpointCount; i++)
+ {
+ int endpointModeClass = ((int)data.Endpoints[i].Mode) >> 2;
+ int classSelectorBit = endpointModeClass - minClass;
+ cemEncoder.PutBits(classSelectorBit, 1);
+ }
+
+ for (int i = 0; i < data.EndpointCount; i++)
+ {
+ int epMode = ((int)data.Endpoints[i].Mode) & 3;
+ cemEncoder.PutBits(epMode, 2);
+ }
+
+ int cemBits = 2 + (partitionCount * 3);
+ if (!cemEncoder.TryGetBits(cemBits, out uint encodedCem))
+ {
+ throw new InvalidOperationException();
+ }
+
+ extraConfig = (int)(encodedCem >> 6);
+
+ bitSink.PutBits(encodedCem, Math.Min(6, cemBits));
+ }
+
+ // dual plane channel
+ if (data.DualPlaneChannel.HasValue)
+ {
+ int channel = data.DualPlaneChannel.Value;
+ ArgumentOutOfRangeException.ThrowIfLessThan(channel, 0);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(channel, 3);
+ extraConfig = (extraConfig << 2) | channel;
+ }
+
+ return (null, extraConfig);
+ }
+
+ private struct BlockModeInfo
+ {
+ public int MinWeightGridDimX;
+ public int MaxWeightGridDimX;
+ public int MinWeightGridDimY;
+ public int MaxWeightGridDimY;
+ public int R0BitPos;
+ public int R1BitPos;
+ public int R2BitPos;
+ public int WeightGridXOffsetBitPos;
+ public int WeightGridYOffsetBitPos;
+ public bool RequireSinglePlaneLowPrec;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/TexelBlock/LogicalBlock.cs b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/LogicalBlock.cs
new file mode 100644
index 00000000..be30dbb0
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/LogicalBlock.cs
@@ -0,0 +1,752 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BiseEncoding.Quantize;
+using SixLabors.ImageSharp.Textures.Compression.Astc.BlockDecoder;
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+using static SixLabors.ImageSharp.Textures.Compression.Astc.Core.Rgba32Extensions;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+internal sealed class LogicalBlock
+{
+ private ColorEndpointPair[] endpoints;
+ private int endpointCount;
+ private readonly int[] weights;
+ private Partition partition;
+ private DualPlaneData? dualPlane;
+
+ public LogicalBlock(Footprint footprint)
+ {
+ this.endpoints = [ColorEndpointPair.Ldr(default, default)];
+ this.endpointCount = 1;
+ this.weights = new int[footprint.PixelCount];
+ this.partition = new Partition(footprint, 1, 0)
+ {
+ Assignment = new int[footprint.PixelCount]
+ };
+ }
+
+ public LogicalBlock(Footprint footprint, in IntermediateBlock.IntermediateBlockData block)
+ {
+ this.endpoints = new ColorEndpointPair[block.EndpointCount];
+ this.endpointCount = DecodeEndpoints(in block, this.endpoints);
+ this.partition = ComputePartition(footprint, in block);
+ this.weights = new int[footprint.PixelCount];
+ this.CalculateWeights(footprint, in block);
+ }
+
+ public LogicalBlock(Footprint footprint, IntermediateBlock.VoidExtentData block)
+ {
+ this.endpoints = new ColorEndpointPair[1];
+ this.endpointCount = DecodeEndpoints(block, this.endpoints);
+ this.partition = ComputePartition(footprint);
+ this.weights = new int[footprint.PixelCount];
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ /// Decodes directly from raw bits + BlockInfo,
+ /// bypassing IntermediateBlock and using batch unquantize operations.
+ ///
+ private LogicalBlock(Footprint footprint, UInt128 bits, in BlockInfo info)
+ {
+ // --- BISE decode + batch unquantize color endpoint values ---
+ Span colors = stackalloc int[info.ColorValuesCount];
+ FusedBlockDecoder.DecodeBiseValues(
+ bits,
+ info.ColorStartBit,
+ info.ColorBitCount,
+ info.ColorValuesRange,
+ info.ColorValuesCount,
+ colors);
+ Quantization.UnquantizeCEValuesBatch(colors, info.ColorValuesCount, info.ColorValuesRange);
+
+ // --- Decode endpoints per partition ---
+ this.endpointCount = info.PartitionCount;
+ this.endpoints = new ColorEndpointPair[this.endpointCount];
+ int colorIndex = 0;
+ for (int i = 0; i < this.endpointCount; i++)
+ {
+ ColorEndpointMode mode = info.GetEndpointMode(i);
+ int colorCount = mode.GetColorValuesCount();
+ ReadOnlySpan slice = colors.Slice(colorIndex, colorCount);
+ this.endpoints[i] = EndpointCodec.DecodeColorsForModePolymorphicUnquantized(slice, mode);
+ colorIndex += colorCount;
+ }
+
+ // --- Set up partition ---
+ this.partition = info.PartitionCount > 1
+ ? Partition.GetASTCPartition(
+ footprint,
+ info.PartitionCount,
+ (int)BitOperations.GetBits(bits.Low(), 13, 10))
+ : GenerateSinglePartition(footprint);
+
+ // --- BISE decode + unquantize + infill weights ---
+ int gridSize = info.GridWidth * info.GridHeight;
+ bool isDualPlane = info.IsDualPlane;
+ int totalWeights = isDualPlane ? gridSize * 2 : gridSize;
+
+ Span rawWeights = stackalloc int[totalWeights];
+ FusedBlockDecoder.DecodeBiseWeights(
+ bits,
+ info.WeightBitCount,
+ info.WeightRange,
+ totalWeights,
+ rawWeights);
+
+ DecimationInfo decimationInfo = DecimationTable.Get(footprint, info.GridWidth, info.GridHeight);
+ this.weights = new int[footprint.PixelCount];
+
+ if (!isDualPlane)
+ {
+ Quantization.UnquantizeWeightsBatch(rawWeights, gridSize, info.WeightRange);
+ DecimationTable.InfillWeights(rawWeights[..gridSize], decimationInfo, this.weights);
+ }
+ else
+ {
+ // De-interleave: even indices -> plane0, odd indices -> plane1
+ Span plane0 = stackalloc int[gridSize];
+ Span plane1 = stackalloc int[gridSize];
+ for (int i = 0; i < gridSize; i++)
+ {
+ plane0[i] = rawWeights[i * 2];
+ plane1[i] = rawWeights[(i * 2) + 1];
+ }
+
+ Quantization.UnquantizeWeightsBatch(plane0, gridSize, info.WeightRange);
+ Quantization.UnquantizeWeightsBatch(plane1, gridSize, info.WeightRange);
+
+ DecimationTable.InfillWeights(plane0, decimationInfo, this.weights);
+
+ this.dualPlane = new DualPlaneData
+ {
+ Channel = info.DualPlaneChannel,
+ Weights = new int[footprint.PixelCount]
+ };
+ DecimationTable.InfillWeights(plane1, decimationInfo, this.dualPlane.Weights);
+ }
+ }
+
+ public Footprint GetFootprint() => this.partition.Footprint;
+
+ public void SetWeightAt(int x, int y, int weight)
+ {
+ if (weight is < 0 or > 64)
+ {
+ throw new ArgumentOutOfRangeException(nameof(weight));
+ }
+
+ this.weights[(y * this.GetFootprint().Width) + x] = weight;
+ }
+
+ public int WeightAt(int x, int y) => this.weights[(y * this.GetFootprint().Width) + x];
+
+ public void SetDualPlaneWeightAt(int channel, int x, int y, int weight)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(channel);
+ ArgumentOutOfRangeException.ThrowIfGreaterThan(weight, 64);
+
+ if (!this.IsDualPlane())
+ {
+ throw new InvalidOperationException("Not a dual plane block");
+ }
+
+ if (this.dualPlane is not null && this.dualPlane.Channel == channel)
+ {
+ this.dualPlane.Weights[(y * this.GetFootprint().Width) + x] = weight;
+ }
+ else
+ {
+ this.SetWeightAt(x, y, weight);
+ }
+ }
+
+ public int DualPlaneWeightAt(int channel, int x, int y)
+ {
+ if (!this.IsDualPlane())
+ {
+ return this.WeightAt(x, y);
+ }
+
+ return this.dualPlane is not null && this.dualPlane.Channel == channel
+ ? this.dualPlane.Weights[(y * this.GetFootprint().Width) + x]
+ : this.WeightAt(x, y);
+ }
+
+ public Rgba32 ColorAt(int x, int y)
+ {
+ Footprint footprint = this.GetFootprint();
+
+ ArgumentOutOfRangeException.ThrowIfNegative(x);
+ ArgumentOutOfRangeException.ThrowIfNegative(y);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(x, footprint.Width);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(y, footprint.Height);
+
+ int index = (y * footprint.Width) + x;
+ int part = this.partition.Assignment[index];
+ ref ColorEndpointPair endpoint = ref this.endpoints[part];
+
+ int weight = this.weights[index];
+ if (!endpoint.IsHdr)
+ {
+ if (this.dualPlane is not null)
+ {
+ return SimdHelpers.InterpolateColorLdrDualPlane(
+ endpoint.LdrLow, endpoint.LdrHigh, weight, this.dualPlane.Channel, this.dualPlane.Weights[index]);
+ }
+
+ return SimdHelpers.InterpolateColorLdr(endpoint.LdrLow, endpoint.LdrHigh, weight);
+ }
+ else
+ {
+ if (this.dualPlane is not null)
+ {
+ int dualPlaneChannel = this.dualPlane.Channel;
+ int dualPlaneWeight = this.dualPlane.Weights[index];
+ int rWeight = dualPlaneChannel == 0 ? dualPlaneWeight : weight;
+ int gWeight = dualPlaneChannel == 1 ? dualPlaneWeight : weight;
+ int bWeight = dualPlaneChannel == 2 ? dualPlaneWeight : weight;
+ int aWeight = dualPlaneChannel == 3 ? dualPlaneWeight : weight;
+ return ClampedRgba32(
+ r: InterpolateChannelHdr(endpoint.HdrLow.R, endpoint.HdrHigh.R, rWeight) >> 8,
+ g: InterpolateChannelHdr(endpoint.HdrLow.G, endpoint.HdrHigh.G, gWeight) >> 8,
+ b: InterpolateChannelHdr(endpoint.HdrLow.B, endpoint.HdrHigh.B, bWeight) >> 8,
+ a: InterpolateChannelHdr(endpoint.HdrLow.A, endpoint.HdrHigh.A, aWeight) >> 8);
+ }
+
+ return ClampedRgba32(
+ r: InterpolateChannelHdr(endpoint.HdrLow.R, endpoint.HdrHigh.R, weight) >> 8,
+ g: InterpolateChannelHdr(endpoint.HdrLow.G, endpoint.HdrHigh.G, weight) >> 8,
+ b: InterpolateChannelHdr(endpoint.HdrLow.B, endpoint.HdrHigh.B, weight) >> 8,
+ a: InterpolateChannelHdr(endpoint.HdrLow.A, endpoint.HdrHigh.A, weight) >> 8);
+ }
+ }
+
+ ///
+ /// Returns the HDR color at the specified pixel position.
+ ///
+ ///
+ /// For HDR endpoints, returns full 16-bit precision (0-65535) per channel.
+ /// For LDR endpoints, upscales to HDR range.
+ ///
+ public Rgba64 ColorAtHdr(int x, int y)
+ {
+ Footprint footprint = this.GetFootprint();
+
+ ArgumentOutOfRangeException.ThrowIfNegative(x);
+ ArgumentOutOfRangeException.ThrowIfNegative(y);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(x, footprint.Width);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(y, footprint.Height);
+
+ int index = (y * footprint.Width) + x;
+ int part = this.partition.Assignment[index];
+ ref ColorEndpointPair endpoint = ref this.endpoints[part];
+
+ int weight = this.weights[index];
+ if (endpoint.IsHdr)
+ {
+ if (this.dualPlane != null)
+ {
+ int dualPlaneChannel = this.dualPlane.Channel;
+ int dualPlaneWeight = this.dualPlane.Weights[index];
+ int rWeight = dualPlaneChannel == 0 ? dualPlaneWeight : weight;
+ int gWeight = dualPlaneChannel == 1 ? dualPlaneWeight : weight;
+ int bWeight = dualPlaneChannel == 2 ? dualPlaneWeight : weight;
+ int aWeight = dualPlaneChannel == 3 ? dualPlaneWeight : weight;
+ return new Rgba64(
+ InterpolateChannelHdr(endpoint.HdrLow.R, endpoint.HdrHigh.R, rWeight),
+ InterpolateChannelHdr(endpoint.HdrLow.G, endpoint.HdrHigh.G, gWeight),
+ InterpolateChannelHdr(endpoint.HdrLow.B, endpoint.HdrHigh.B, bWeight),
+ InterpolateChannelHdr(endpoint.HdrLow.A, endpoint.HdrHigh.A, aWeight));
+ }
+
+ return new Rgba64(
+ InterpolateChannelHdr(endpoint.HdrLow.R, endpoint.HdrHigh.R, weight),
+ InterpolateChannelHdr(endpoint.HdrLow.G, endpoint.HdrHigh.G, weight),
+ InterpolateChannelHdr(endpoint.HdrLow.B, endpoint.HdrHigh.B, weight),
+ InterpolateChannelHdr(endpoint.HdrLow.A, endpoint.HdrHigh.A, weight));
+ }
+ else
+ {
+ if (this.dualPlane != null)
+ {
+ int dualPlaneChannel = this.dualPlane.Channel;
+ int dualPlaneWeight = this.dualPlane.Weights[index];
+ int rWeight = dualPlaneChannel == 0 ? dualPlaneWeight : weight;
+ int gWeight = dualPlaneChannel == 1 ? dualPlaneWeight : weight;
+ int bWeight = dualPlaneChannel == 2 ? dualPlaneWeight : weight;
+ int aWeight = dualPlaneChannel == 3 ? dualPlaneWeight : weight;
+ return new Rgba64(
+ (ushort)(InterpolateChannel(endpoint.LdrLow.R, endpoint.LdrHigh.R, rWeight) * 257),
+ (ushort)(InterpolateChannel(endpoint.LdrLow.G, endpoint.LdrHigh.G, gWeight) * 257),
+ (ushort)(InterpolateChannel(endpoint.LdrLow.B, endpoint.LdrHigh.B, bWeight) * 257),
+ (ushort)(InterpolateChannel(endpoint.LdrLow.A, endpoint.LdrHigh.A, aWeight) * 257));
+ }
+
+ return new Rgba64(
+ (ushort)(InterpolateChannel(endpoint.LdrLow.R, endpoint.LdrHigh.R, weight) * 257),
+ (ushort)(InterpolateChannel(endpoint.LdrLow.G, endpoint.LdrHigh.G, weight) * 257),
+ (ushort)(InterpolateChannel(endpoint.LdrLow.B, endpoint.LdrHigh.B, weight) * 257),
+ (ushort)(InterpolateChannel(endpoint.LdrLow.A, endpoint.LdrHigh.A, weight) * 257));
+ }
+ }
+
+ ///
+ /// Writes the HDR float values for the pixel at (x, y) into the output span.
+ ///
+ ///
+ /// For HDR endpoints, values are in LNS (Log-Normalized Space). After interpolation
+ /// in LNS, the result is converted to FP16 via then widened to float.
+ /// For Mode 14 (HDR RGB + LDR Alpha), the alpha channel is UNORM16 instead of LNS.
+ /// For LDR endpoints, the interpolated UNORM16 value is normalized to 0.0-1.0.
+ ///
+ public void WriteHdrPixel(int x, int y, Span output)
+ {
+ Footprint footprint = this.GetFootprint();
+
+ ArgumentOutOfRangeException.ThrowIfNegative(x);
+ ArgumentOutOfRangeException.ThrowIfNegative(y);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(x, footprint.Width);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(y, footprint.Height);
+
+ int index = (y * footprint.Width) + x;
+ int part = this.partition.Assignment[index];
+ ref ColorEndpointPair endpoint = ref this.endpoints[part];
+
+ int weight = this.weights[index];
+ int dualPlaneChannel = this.dualPlane?.Channel ?? -1;
+ int dualPlaneWeight = this.dualPlane?.Weights[index] ?? weight;
+
+ if (endpoint.IsHdr)
+ {
+ for (int channel = 0; channel < 4; ++channel)
+ {
+ int channelWeight = (channel == dualPlaneChannel)
+ ? dualPlaneWeight
+ : weight;
+ ushort interpolated = InterpolateChannelHdr(endpoint.HdrLow.GetChannel(channel), endpoint.HdrHigh.GetChannel(channel), channelWeight);
+
+ if (channel == 3 && endpoint.AlphaIsLdr)
+ {
+ // Mode 14: alpha is UNORM16, normalize directly
+ output[channel] = interpolated / 65535.0f;
+ }
+ else if (endpoint.ValuesAreLns)
+ {
+ // Normal HDR block: convert from LNS to FP16, then to float
+ ushort halfFloatBits = LnsToSf16(interpolated);
+ output[channel] = (float)BitConverter.UInt16BitsToHalf(halfFloatBits);
+ }
+ else
+ {
+ // Void extent HDR: values are already FP16 bit patterns
+ output[channel] = (float)BitConverter.UInt16BitsToHalf(interpolated);
+ }
+ }
+ }
+ else
+ {
+ for (int channel = 0; channel < 4; ++channel)
+ {
+ int channelWeight = (channel == dualPlaneChannel)
+ ? dualPlaneWeight
+ : weight;
+ int p0 = endpoint.LdrLow.GetChannel(channel);
+ int p1 = endpoint.LdrHigh.GetChannel(channel);
+ ushort unorm16 = InterpolateLdrAsUnorm16(p0, p1, channelWeight);
+ output[channel] = unorm16 / 65535.0f;
+ }
+ }
+ }
+
+ ///
+ /// Writes all pixels in the block directly to the output buffer in RGBA byte format.
+ /// Avoids per-pixel method call overhead, type dispatch, and Rgba32 allocation.
+ ///
+ public void WriteAllPixelsLdr(Footprint footprint, Span buffer)
+ {
+ ref ColorEndpointPair endpoint0 = ref this.endpoints[0];
+
+ if (!endpoint0.IsHdr && this.partition.PartitionCount == 1)
+ {
+ // Fast path: single-partition LDR block (most common case)
+ int lowR = endpoint0.LdrLow.R, lowG = endpoint0.LdrLow.G, lowB = endpoint0.LdrLow.B, lowA = endpoint0.LdrLow.A;
+ int highR = endpoint0.LdrHigh.R, highG = endpoint0.LdrHigh.G, highB = endpoint0.LdrHigh.B, highA = endpoint0.LdrHigh.A;
+
+ if (this.dualPlane == null)
+ {
+ this.WriteLdrSinglePartition(buffer, footprint, lowR, lowG, lowB, lowA, highR, highG, highB, highA);
+ }
+ else
+ {
+ int dualPlaneChannel = this.dualPlane.Channel;
+ int[] dpWeights = this.dualPlane.Weights;
+ int pixelCount = footprint.PixelCount;
+ for (int i = 0; i < pixelCount; i++)
+ {
+ SimdHelpers.WriteSinglePixelLdrDualPlane(
+ buffer,
+ i * 4,
+ lowR,
+ lowG,
+ lowB,
+ lowA,
+ highR,
+ highG,
+ highB,
+ highA,
+ this.weights[i],
+ dualPlaneChannel,
+ dpWeights[i]);
+ }
+ }
+ }
+ else
+ {
+ // General path: multi-partition or HDR blocks
+ this.WriteAllPixelsGeneral(footprint, buffer);
+ }
+ }
+
+ public void SetPartition(Partition p)
+ {
+ if (!p.Footprint.Equals(this.partition.Footprint))
+ {
+ throw new InvalidOperationException("New partitions may not be for a different footprint");
+ }
+
+ this.partition = p;
+ if (this.endpointCount < p.PartitionCount)
+ {
+ ColorEndpointPair[] newEndpoints = new ColorEndpointPair[p.PartitionCount];
+ Array.Copy(this.endpoints, newEndpoints, this.endpointCount);
+ for (int i = this.endpointCount; i < p.PartitionCount; i++)
+ {
+ newEndpoints[i] = ColorEndpointPair.Ldr(default, default);
+ }
+
+ this.endpoints = newEndpoints;
+ }
+
+ this.endpointCount = p.PartitionCount;
+ }
+
+ public void SetEndpoints(Rgba32 firstEndpoint, Rgba32 secondEndpoint, int subset)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegative(subset);
+ ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(subset, this.partition.PartitionCount);
+
+ this.endpoints[subset] = ColorEndpointPair.Ldr(firstEndpoint, secondEndpoint);
+ }
+
+ public void SetDualPlaneChannel(int channel)
+ {
+ if (channel < 0)
+ {
+ this.dualPlane = null;
+ }
+ else if (this.dualPlane != null)
+ {
+ this.dualPlane.Channel = channel;
+ }
+ else
+ {
+ this.dualPlane = new DualPlaneData { Channel = channel, Weights = (int[])this.weights.Clone() };
+ }
+ }
+
+ public bool IsDualPlane() => this.dualPlane is not null;
+
+ public static LogicalBlock? UnpackLogicalBlock(Footprint footprint, UInt128 bits, in BlockInfo info)
+ {
+ if (!info.IsValid)
+ {
+ return null;
+ }
+
+ if (info.IsVoidExtent)
+ {
+ // Void extent blocks are rare; fall back to existing PhysicalBlock path
+ PhysicalBlock pb = PhysicalBlock.Create(bits);
+ IntermediateBlock.VoidExtentData? voidExtentData = IntermediateBlock.UnpackVoidExtent(pb);
+ if (voidExtentData is null)
+ {
+ return null;
+ }
+
+ return new LogicalBlock(footprint, voidExtentData.Value);
+ }
+ else
+ {
+ return new LogicalBlock(footprint, bits, in info);
+ }
+ }
+
+ ///
+ /// Converts a 16-bit LNS (Log-Normalized Space) value to a 16-bit SF16 (FP16) bit pattern.
+ ///
+ ///
+ /// The LNS value encodes a 5-bit exponent in the upper bits and an 11-bit mantissa
+ /// in the lower bits. The mantissa is transformed using a piecewise linear function
+ /// before being combined with the exponent to form the FP16 result.
+ ///
+ internal static ushort LnsToSf16(int lns)
+ {
+ int mantissaComponent = lns & 0x7FF; // Lower 11 bits: mantissa component
+ int exponentComponent = (lns >> 11) & 0x1F; // Upper 5 bits: exponent component
+
+ int mantissaTransformed;
+ if (mantissaComponent < 512)
+ {
+ mantissaTransformed = mantissaComponent * 3;
+ }
+ else if (mantissaComponent < 1536)
+ {
+ mantissaTransformed = (mantissaComponent * 4) - 512;
+ }
+ else
+ {
+ mantissaTransformed = (mantissaComponent * 5) - 2048;
+ }
+
+ int result = (exponentComponent << 10) | (mantissaTransformed >> 3);
+ return (ushort)Math.Min(result, 0x7BFF); // Clamp to max finite FP16
+ }
+
+ private static int DecodeEndpoints(in IntermediateBlock.IntermediateBlockData block, ColorEndpointPair[] endpointPair)
+ {
+ int endpointRange = block.EndpointRange ?? IntermediateBlock.EndpointRangeForBlock(block);
+ if (endpointRange <= 0)
+ {
+ throw new InvalidOperationException("Invalid endpoint range");
+ }
+
+ for (int i = 0; i < block.EndpointCount; i++)
+ {
+ IntermediateBlock.IntermediateEndpointData ed = block.Endpoints[i];
+ ReadOnlySpan colorSpan = ((ReadOnlySpan)ed.Colors)[..ed.ColorCount];
+ endpointPair[i] = EndpointCodec.DecodeColorsForModePolymorphic(colorSpan, endpointRange, ed.Mode);
+ }
+
+ return block.EndpointCount;
+ }
+
+ private static int DecodeEndpoints(IntermediateBlock.VoidExtentData block, ColorEndpointPair[] endpointPair)
+ {
+ if (block.IsHdr)
+ {
+ // HDR void extent: ushort values are FP16 bit patterns (not LNS)
+ Rgba64 hdrColor = new(block.R, block.G, block.B, block.A);
+ endpointPair[0] = ColorEndpointPair.Hdr(hdrColor, hdrColor, valuesAreLns: false);
+ }
+ else
+ {
+ // LDR void extent: ushort values are UNORM16, convert to byte range
+ Rgba32 ldrColor = new(
+ (byte)(block.R >> 8),
+ (byte)(block.G >> 8),
+ (byte)(block.B >> 8),
+ (byte)(block.A >> 8));
+ endpointPair[0] = ColorEndpointPair.Ldr(ldrColor, ldrColor);
+ }
+
+ return 1;
+ }
+
+ private static Partition GenerateSinglePartition(Footprint footprint) => new(footprint, 1, 0)
+ {
+ Assignment = new int[footprint.PixelCount]
+ };
+
+ private static Partition ComputePartition(Footprint footprint, in IntermediateBlock.IntermediateBlockData block)
+ => block.PartitionId.HasValue
+ ? Partition.GetASTCPartition(footprint, block.EndpointCount, block.PartitionId.Value)
+ : GenerateSinglePartition(footprint);
+
+ private static Partition ComputePartition(Footprint footprint)
+ => GenerateSinglePartition(footprint);
+
+ private void CalculateWeights(Footprint footprint, in IntermediateBlock.IntermediateBlockData block)
+ {
+ int gridSize = block.WeightGridX * block.WeightGridY;
+ int weightFrequency = block.DualPlaneChannel.HasValue ? 2 : 1;
+
+ // Get decimation info once for both planes
+ DecimationInfo decimationInfo = DecimationTable.Get(footprint, block.WeightGridX, block.WeightGridY);
+
+ // stackalloc avoids per-block heap allocation (max 12×12 = 144 ints = 576 bytes)
+ Span unquantized = stackalloc int[gridSize];
+ for (int i = 0; i < gridSize; ++i)
+ {
+ unquantized[i] = Quantization.UnquantizeWeightFromRange(
+ block.Weights[i * weightFrequency], block.WeightRange);
+ }
+
+ DecimationTable.InfillWeights(unquantized, decimationInfo, this.weights);
+
+ if (block.DualPlaneChannel.HasValue)
+ {
+ DualPlaneData dualPlane = new()
+ {
+ Channel = block.DualPlaneChannel.Value,
+ Weights = new int[footprint.PixelCount]
+ };
+ this.dualPlane = dualPlane;
+ for (int i = 0; i < gridSize; ++i)
+ {
+ unquantized[i] = Quantization.UnquantizeWeightFromRange(
+ block.Weights[(i * weightFrequency) + 1], block.WeightRange);
+ }
+
+ DecimationTable.InfillWeights(unquantized, decimationInfo, this.dualPlane.Weights);
+ }
+ }
+
+ private static int InterpolateChannel(int p0, int p1, int weight)
+ {
+ int c0 = (p0 << 8) | p0;
+ int c1 = (p1 << 8) | p1;
+ int c = ((c0 * (64 - weight)) + (c1 * weight) + 32) / 64;
+ int quantized = ((c * byte.MaxValue) + short.MaxValue) / (ushort.MaxValue + 1);
+ return Math.Clamp(quantized, 0, byte.MaxValue);
+ }
+
+ ///
+ /// Interpolates an LDR channel value and returns the full 16-bit UNORM result
+ /// (before reduction to byte). Used by the HDR output path for LDR endpoints.
+ ///
+ private static ushort InterpolateLdrAsUnorm16(int p0, int p1, int weight)
+ {
+ int c0 = (p0 << 8) | p0;
+ int c1 = (p1 << 8) | p1;
+ int c = ((c0 * (64 - weight)) + (c1 * weight) + 32) / 64;
+ return (ushort)Math.Clamp(c, 0, 0xFFFF);
+ }
+
+ ///
+ /// Interpolates an HDR channel value between two endpoints using the specified weight.
+ ///
+ ///
+ /// HDR endpoints are already 16-bit values (FP16 bit patterns). Unlike LDR interpolation
+ /// which expands 8-bit to 16-bit before interpolating, HDR interpolation operates directly
+ /// on the 16-bit values
+ ///
+ private static ushort InterpolateChannelHdr(int p0, int p1, int weight)
+ {
+ int c = ((p0 * (64 - weight)) + (p1 * weight) + 32) / 64;
+ return (ushort)Math.Clamp(c, 0, 0xFFFF);
+ }
+
+ private void WriteLdrSinglePartition(
+ Span buffer,
+ Footprint footprint,
+ int lowR,
+ int lowG,
+ int lowB,
+ int lowA,
+ int highR,
+ int highG,
+ int highB,
+ int highA)
+ {
+ int pixelCount = footprint.PixelCount;
+ for (int i = 0; i < pixelCount; i++)
+ {
+ SimdHelpers.WriteSinglePixelLdr(
+ buffer,
+ i * 4,
+ lowR,
+ lowG,
+ lowB,
+ lowA,
+ highR,
+ highG,
+ highB,
+ highA,
+ this.weights[i]);
+ }
+ }
+
+ private void WriteAllPixelsGeneral(Footprint footprint, Span buffer)
+ {
+ int pixelCount = footprint.PixelCount;
+ for (int i = 0; i < pixelCount; i++)
+ {
+ int part = this.partition.Assignment[i];
+ ref ColorEndpointPair endpoint = ref this.endpoints[part];
+
+ int weight = this.weights[i];
+ if (!endpoint.IsHdr)
+ {
+ if (this.dualPlane is not null)
+ {
+ SimdHelpers.WriteSinglePixelLdrDualPlane(
+ buffer,
+ i * 4,
+ endpoint.LdrLow.R,
+ endpoint.LdrLow.G,
+ endpoint.LdrLow.B,
+ endpoint.LdrLow.A,
+ endpoint.LdrHigh.R,
+ endpoint.LdrHigh.G,
+ endpoint.LdrHigh.B,
+ endpoint.LdrHigh.A,
+ weight,
+ this.dualPlane.Channel,
+ this.dualPlane.Weights[i]);
+ }
+ else
+ {
+ SimdHelpers.WriteSinglePixelLdr(
+ buffer,
+ i * 4,
+ endpoint.LdrLow.R,
+ endpoint.LdrLow.G,
+ endpoint.LdrLow.B,
+ endpoint.LdrLow.A,
+ endpoint.LdrHigh.R,
+ endpoint.LdrHigh.G,
+ endpoint.LdrHigh.B,
+ endpoint.LdrHigh.A,
+ weight);
+ }
+ }
+ else
+ {
+ int dualPlaneChannel = this.dualPlane?.Channel ?? -1;
+ int dualPlaneWeight = this.dualPlane?.Weights[i] ?? weight;
+ int rWeight = dualPlaneChannel == 0 ? dualPlaneWeight : weight;
+ int gWeight = dualPlaneChannel == 1 ? dualPlaneWeight : weight;
+ int bWeight = dualPlaneChannel == 2 ? dualPlaneWeight : weight;
+ int aWeight = dualPlaneChannel == 3 ? dualPlaneWeight : weight;
+ buffer[(i * 4) + 0] = (byte)(InterpolateChannelHdr(
+ endpoint.HdrLow.R,
+ endpoint.HdrHigh.R,
+ rWeight) >> 8);
+ buffer[(i * 4) + 1] = (byte)(InterpolateChannelHdr(
+ endpoint.HdrLow.G,
+ endpoint.HdrHigh.G,
+ gWeight) >> 8);
+ buffer[(i * 4) + 2] = (byte)(InterpolateChannelHdr(
+ endpoint.HdrLow.B,
+ endpoint.HdrHigh.B,
+ bWeight) >> 8);
+ buffer[(i * 4) + 3] = (byte)(InterpolateChannelHdr(
+ endpoint.HdrLow.A,
+ endpoint.HdrHigh.A,
+ aWeight) >> 8);
+ }
+ }
+ }
+
+ private class DualPlaneData
+ {
+ public int Channel { get; set; }
+
+ public int[] Weights { get; set; } = [];
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/TexelBlock/PhysicalBlock.cs b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/PhysicalBlock.cs
new file mode 100644
index 00000000..2c3c8481
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/PhysicalBlock.cs
@@ -0,0 +1,216 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using SixLabors.ImageSharp.Textures.Compression.Astc.ColorEncoding;
+using SixLabors.ImageSharp.Textures.Compression.Astc.Core;
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+///
+/// A physical ASTC texel block (128 bits).
+/// Delegates all block mode decoding to .
+///
+internal readonly struct PhysicalBlock
+{
+ public const int SizeInBytes = 16;
+ private readonly BlockInfo info;
+
+ private PhysicalBlock(UInt128 bits, BlockInfo info)
+ {
+ this.BlockBits = bits;
+ this.info = info;
+ }
+
+ public UInt128 BlockBits { get; }
+
+ public bool IsVoidExtent => this.info.IsVoidExtent;
+
+ public bool IsIllegalEncoding => !this.info.IsValid;
+
+ public bool IsDualPlane
+ => this.info.IsValid && !this.info.IsVoidExtent && this.info.IsDualPlane;
+
+ ///
+ /// Factory method to create a PhysicalBlock from raw bits
+ ///
+ public static PhysicalBlock Create(UInt128 bits)
+ => new(bits, BlockInfo.Decode(bits));
+
+ public static PhysicalBlock Create(ulong low) => Create((UInt128)low);
+
+ public static PhysicalBlock Create(ulong low, ulong high) => Create(new UInt128(high, low));
+
+ internal (int Width, int Height)? GetWeightGridDimensions()
+ => this.info.IsValid && !this.info.IsVoidExtent
+ ? (this.info.GridWidth, this.info.GridHeight)
+ : null;
+
+ internal int? GetWeightRange()
+ => this.info.IsValid && !this.info.IsVoidExtent
+ ? this.info.WeightRange
+ : null;
+
+ internal int[]? GetVoidExtentCoordinates()
+ {
+ if (!this.info.IsVoidExtent)
+ {
+ return null;
+ }
+
+ // If void extent coords are all 1's then these are not valid void extent coords
+ ulong voidExtentMask = 0xFFFFFFFFFFFFFDFFUL;
+ ulong constBlockMode = 0xFFFFFFFFFFFFFDFCUL;
+
+ return this.info.IsValid && (voidExtentMask & this.BlockBits.Low()) != constBlockMode
+ ? DecodeVoidExtentCoordinates(this.BlockBits)
+ : null;
+ }
+
+ ///
+ /// Get the dual plane channel if dual plane is enabled
+ ///
+ /// The dual plane channel if enabled, otherwise null.
+ internal int? GetDualPlaneChannel()
+ => this.info.IsValid && this.info.IsDualPlane
+ ? this.info.DualPlaneChannel
+ : null;
+
+ internal string? IdentifyInvalidEncodingIssues()
+ {
+ if (this.info.IsValid)
+ {
+ return null;
+ }
+
+ return this.info.IsVoidExtent
+ ? IdentifyVoidExtentIssues(this.BlockBits)
+ : "Invalid block encoding";
+ }
+
+ internal int? GetWeightBitCount()
+ => this.info.IsValid && !this.info.IsVoidExtent
+ ? this.info.WeightBitCount
+ : null;
+
+ internal int? GetWeightStartBit()
+ => this.info.IsValid && !this.info.IsVoidExtent
+ ? 128 - this.info.WeightBitCount
+ : null;
+
+ internal int? GetPartitionsCount()
+ => this.info.IsValid && !this.info.IsVoidExtent
+ ? this.info.PartitionCount
+ : null;
+
+ internal int? GetPartitionId()
+ {
+ if (!this.info.IsValid || this.info.IsVoidExtent || this.info.PartitionCount == 1)
+ {
+ return null;
+ }
+
+ return (int)BitOperations.GetBits(this.BlockBits.Low(), 13, 10);
+ }
+
+ internal ColorEndpointMode? GetEndpointMode(int partition)
+ {
+ if (!this.info.IsValid || this.info.IsVoidExtent)
+ {
+ return null;
+ }
+
+ if (partition < 0 || partition >= this.info.PartitionCount)
+ {
+ return null;
+ }
+
+ return this.info.GetEndpointMode(partition);
+ }
+
+ internal int? GetColorStartBit()
+ {
+ if (this.info.IsVoidExtent)
+ {
+ return 64;
+ }
+
+ return this.info.IsValid
+ ? this.info.ColorStartBit
+ : null;
+ }
+
+ internal int? GetColorValuesCount()
+ {
+ if (this.info.IsVoidExtent)
+ {
+ return 4;
+ }
+
+ return this.info.IsValid
+ ? this.info.ColorValuesCount
+ : null;
+ }
+
+ internal int? GetColorBitCount()
+ {
+ if (this.info.IsVoidExtent)
+ {
+ return 64;
+ }
+
+ return this.info.IsValid
+ ? this.info.ColorBitCount
+ : null;
+ }
+
+ internal int? GetColorValuesRange()
+ {
+ if (this.info.IsVoidExtent)
+ {
+ return (1 << 16) - 1;
+ }
+
+ return this.info.IsValid
+ ? this.info.ColorValuesRange
+ : null;
+ }
+
+ internal static int[] DecodeVoidExtentCoordinates(UInt128 astcBits)
+ {
+ ulong lowBits = astcBits.Low();
+ int[] coords = new int[4];
+ for (int i = 0; i < 4; ++i)
+ {
+ coords[i] = (int)BitOperations.GetBits(lowBits, 12 + (13 * i), 13);
+ }
+
+ return coords;
+ }
+
+ ///
+ /// Full error-string version for void extent issues (used for error reporting)
+ ///
+ private static string? IdentifyVoidExtentIssues(UInt128 bits)
+ {
+ if (BitOperations.GetBits(bits, 10, 2).Low() != 0x3UL)
+ {
+ return "Reserved bits set for void extent block";
+ }
+
+ ulong lowBits = bits.Low();
+ int c0 = (int)BitOperations.GetBits(lowBits, 12, 13);
+ int c1 = (int)BitOperations.GetBits(lowBits, 25, 13);
+ int c2 = (int)BitOperations.GetBits(lowBits, 38, 13);
+ int c3 = (int)BitOperations.GetBits(lowBits, 51, 13);
+
+ const int all1s = (1 << 13) - 1;
+ bool coordsAll1s = c0 == all1s && c1 == all1s && c2 == all1s && c3 == all1s;
+
+ if (!coordsAll1s && (c0 >= c1 || c2 >= c3))
+ {
+ return "Void extent texture coordinates are invalid";
+ }
+
+ return null;
+ }
+}
diff --git a/src/ImageSharp.Textures/Compression/Astc/TexelBlock/PhysicalBlockMode.cs b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/PhysicalBlockMode.cs
new file mode 100644
index 00000000..fe2a4f9c
--- /dev/null
+++ b/src/ImageSharp.Textures/Compression/Astc/TexelBlock/PhysicalBlockMode.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+namespace SixLabors.ImageSharp.Textures.Compression.Astc.TexelBlock;
+
+///
+/// The overall block modes defined in table C.2.8. There are 10
+/// weight grid encoding schemes + void extent.
+///
+internal enum PhysicalBlockMode
+{
+ WidthB4HeightA2,
+ WidthB8HeightA2,
+ WidthA2HeightB8,
+ WidthA2HeightB6,
+ WidthB2HeightA2,
+ Width12HeightA2,
+ WidthA2Height12,
+ Width6Height10,
+ Width10Height6,
+ WidthA6HeightB6,
+ VoidExtent,
+}
diff --git a/src/ImageSharp.Textures/Formats/Ktx/Enums/GlInternalPixelFormat.cs b/src/ImageSharp.Textures/Formats/Ktx/Enums/GlInternalPixelFormat.cs
index 0597c744..3232cadb 100644
--- a/src/ImageSharp.Textures/Formats/Ktx/Enums/GlInternalPixelFormat.cs
+++ b/src/ImageSharp.Textures/Formats/Ktx/Enums/GlInternalPixelFormat.cs
@@ -45,6 +45,22 @@ internal enum GlInternalPixelFormat : uint
Rgba16 = 0x805B,
+ R16F = 0x822D,
+
+ Rg16F = 0x822F,
+
+ R32F = 0x822E,
+
+ Rg32F = 0x8230,
+
+ Rgb16F = 0x881B,
+
+ Rgba16F = 0x881A,
+
+ Rgb32F = 0x8815,
+
+ Rgba32F = 0x8814,
+
R8 = 0x8229,
R8UnsignedInt = 0x8232,
@@ -131,6 +147,11 @@ internal enum GlInternalPixelFormat : uint
CompressedSrgb8Alpha8Etc2Eac = 0x9279,
+ ///
+ /// ASTC 4x4 block compression. Supports both LDR and HDR content.
+ /// HDR is determined by block encoding, not a separate format constant.
+ /// Note: Current decoder may not fully support HDR endpoint modes (2, 3, 7, 11, 14, 15).
+ ///
CompressedRgbaAstc4x4Khr = 0x93B0,
CompressedRgbaAstc5x4Khr = 0x93B1,
@@ -158,5 +179,37 @@ internal enum GlInternalPixelFormat : uint
CompressedRgbaAstc12x10Khr = 0x93BC,
CompressedRgbaAstc12x12Khr = 0x93BD,
+
+ ///
+ /// ASTC 4x4 block compression with sRGB color space.
+ /// HDR blocks in sRGB formats will decode incorrectly.
+ ///
+ CompressedSrgb8Alpha8Astc4x4Khr = 0x93D0,
+
+ CompressedSrgb8Alpha8Astc5x4Khr = 0x93D1,
+
+ CompressedSrgb8Alpha8Astc5x5Khr = 0x93D2,
+
+ CompressedSrgb8Alpha8Astc6x5Khr = 0x93D3,
+
+ CompressedSrgb8Alpha8Astc6x6Khr = 0x93D4,
+
+ CompressedSrgb8Alpha8Astc8x5Khr = 0x93D5,
+
+ CompressedSrgb8Alpha8Astc8x6Khr = 0x93D6,
+
+ CompressedSrgb8Alpha8Astc8x8Khr = 0x93D7,
+
+ CompressedSrgb8Alpha8Astc10x5Khr = 0x93D8,
+
+ CompressedSrgb8Alpha8Astc10x6Khr = 0x93D9,
+
+ CompressedSrgb8Alpha8Astc10x8Khr = 0x93DA,
+
+ CompressedSrgb8Alpha8Astc10x10Khr = 0x93DB,
+
+ CompressedSrgb8Alpha8Astc12x10Khr = 0x93DC,
+
+ CompressedSrgb8Alpha8Astc12x12Khr = 0x93DD,
}
}
diff --git a/src/ImageSharp.Textures/Formats/Ktx/IEndianHandler.cs b/src/ImageSharp.Textures/Formats/Ktx/IEndianHandler.cs
new file mode 100644
index 00000000..6b424f26
--- /dev/null
+++ b/src/ImageSharp.Textures/Formats/Ktx/IEndianHandler.cs
@@ -0,0 +1,27 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System;
+
+namespace SixLabors.ImageSharp.Textures.Formats.Ktx
+{
+ ///
+ /// Handles endianness conversions for KTX texture data.
+ ///
+ internal interface IEndianHandler
+ {
+ ///
+ /// Reads a UInt32 value from a buffer with appropriate endianness.
+ ///
+ /// The buffer containing the UInt32 data.
+ /// The UInt32 value.
+ uint ReadUInt32(ReadOnlySpan buffer);
+
+ ///
+ /// Converts pixel data endianness if needed based on the type size.
+ ///
+ /// The pixel data to convert.
+ /// The size of each data element (2 for half-float, 4 for float).
+ void ConvertPixelData(Span data, uint typeSize);
+ }
+}
diff --git a/src/ImageSharp.Textures/Formats/Ktx/KtxProcessor.cs b/src/ImageSharp.Textures/Formats/Ktx/KtxProcessor.cs
index 88ac7f20..3ea03401 100644
--- a/src/ImageSharp.Textures/Formats/Ktx/KtxProcessor.cs
+++ b/src/ImageSharp.Textures/Formats/Ktx/KtxProcessor.cs
@@ -20,11 +20,27 @@ internal class KtxProcessor
///
private readonly byte[] buffer = new byte[4];
+ ///
+ /// The endian handler for reading and converting data based on file endianness.
+ ///
+ private readonly IEndianHandler endianHandler;
+
///
/// Initializes a new instance of the class.
///
/// The KTX header.
- public KtxProcessor(KtxHeader ktxHeader) => this.KtxHeader = ktxHeader;
+ public KtxProcessor(KtxHeader ktxHeader)
+ {
+ this.KtxHeader = ktxHeader;
+
+ bool isFileLittleEndian = ktxHeader.Endianness == KtxEndianness.LittleEndian;
+ bool isSystemLittleEndian = BitConverter.IsLittleEndian;
+
+ // Use appropriate handler based on whether endianness matches
+ this.endianHandler = isFileLittleEndian == isSystemLittleEndian
+ ? new NativeEndianHandler(isFileLittleEndian)
+ : new SwappingEndianHandler(isFileLittleEndian);
+ }
///
/// Gets the KTX header.
@@ -41,7 +57,7 @@ internal class KtxProcessor
/// The decoded mipmaps.
public MipMap[] DecodeMipMaps(Stream stream, int width, int height, uint count)
{
- if (this.KtxHeader.GlTypeSize == 1)
+ if (this.KtxHeader.GlTypeSize is 0 or 1)
{
switch (this.KtxHeader.GlFormat)
{
@@ -86,15 +102,56 @@ public MipMap[] DecodeMipMaps(Stream stream, int width, int height, uint count)
return this.AllocateMipMaps(stream, width, height, count);
case GlInternalPixelFormat.CompressedRgb8Etc2:
return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc4x4Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc4x4Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc5x4Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc5x4Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc5x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc5x5Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc6x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc6x5Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc6x6Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc6x6Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc8x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc8x5Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc8x6Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc8x6Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc8x8Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc8x8Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x5Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x6Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x6Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x8Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x8Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x10Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x10Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc12x10Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc12x10Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.CompressedRgbaAstc12x12Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc12x12Khr:
+ return this.AllocateMipMaps(stream, width, height, count);
}
break;
}
}
- if (this.KtxHeader.GlTypeSize == 2 || this.KtxHeader.GlTypeSize == 4)
+ if (this.KtxHeader.GlTypeSize is 2 or 4)
{
- // TODO: endianess is not respected here. Use stream reader which respects endianess.
switch (this.KtxHeader.GlInternalFormat)
{
case GlInternalPixelFormat.Rgb5A1:
@@ -107,6 +164,24 @@ public MipMap[] DecodeMipMaps(Stream stream, int width, int height, uint count)
return this.AllocateMipMaps(stream, width, height, count);
case GlInternalPixelFormat.Rgba32UnsignedInt:
return this.AllocateMipMaps(stream, width, height, count);
+
+ // HDR floating-point formats
+ case GlInternalPixelFormat.R16F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.Rg16F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.Rgb16F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.Rgba16F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.R32F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.Rg32F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.Rgb32F:
+ return this.AllocateMipMaps(stream, width, height, count);
+ case GlInternalPixelFormat.Rgba32F:
+ return this.AllocateMipMaps(stream, width, height, count);
}
}
@@ -166,14 +241,55 @@ public CubemapTexture DecodeCubeMap(Stream stream, int width, int height)
return this.AllocateCubeMap(stream, width, height);
case GlInternalPixelFormat.CompressedRgb8Etc2:
return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc4x4Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc4x4Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc5x4Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc5x4Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc5x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc5x5Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc6x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc6x5Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc6x6Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc6x6Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc8x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc8x5Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc8x6Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc8x6Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc8x8Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc8x8Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x5Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x5Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x6Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x6Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x8Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x8Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc10x10Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc10x10Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc12x10Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc12x10Khr:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.CompressedRgbaAstc12x12Khr:
+ case GlInternalPixelFormat.CompressedSrgb8Alpha8Astc12x12Khr:
+ return this.AllocateCubeMap(stream, width, height);
}
break;
}
- if (this.KtxHeader.GlTypeSize == 2 || this.KtxHeader.GlTypeSize == 4)
+ if (this.KtxHeader.GlTypeSize is 2 or 4)
{
- // TODO: endianess is not respected here. Use stream reader which respects endianess.
switch (this.KtxHeader.GlInternalFormat)
{
case GlInternalPixelFormat.Rgb5A1:
@@ -186,6 +302,24 @@ public CubemapTexture DecodeCubeMap(Stream stream, int width, int height)
return this.AllocateCubeMap(stream, width, height);
case GlInternalPixelFormat.Rgba32UnsignedInt:
return this.AllocateCubeMap(stream, width, height);
+
+ // HDR floating-point formats
+ case GlInternalPixelFormat.R16F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.Rg16F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.Rgb16F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.Rgba16F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.R32F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.Rg32F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.Rgb32F:
+ return this.AllocateCubeMap(stream, width, height);
+ case GlInternalPixelFormat.Rgba32F:
+ return this.AllocateCubeMap(stream, width, height);
}
}
@@ -207,15 +341,16 @@ private CubemapTexture AllocateCubeMap(Stream stream, int width, int hei
var cubeMapTexture = new CubemapTexture();
var blockFormat = default(TBlock);
+
for (int i = 0; i < numberOfMipMaps; i++)
{
var dataForEachFace = this.ReadTextureDataSize(stream);
- cubeMapTexture.PositiveX.MipMaps.Add(ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
- cubeMapTexture.NegativeX.MipMaps.Add(ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
- cubeMapTexture.PositiveY.MipMaps.Add(ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
- cubeMapTexture.NegativeY.MipMaps.Add(ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
- cubeMapTexture.PositiveZ.MipMaps.Add(ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
- cubeMapTexture.NegativeZ.MipMaps.Add(ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
+ cubeMapTexture.PositiveX.MipMaps.Add(this.ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
+ cubeMapTexture.NegativeX.MipMaps.Add(this.ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
+ cubeMapTexture.PositiveY.MipMaps.Add(this.ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
+ cubeMapTexture.NegativeY.MipMaps.Add(this.ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
+ cubeMapTexture.PositiveZ.MipMaps.Add(this.ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
+ cubeMapTexture.NegativeZ.MipMaps.Add(this.ReadFaceTexture(stream, width, height, blockFormat, dataForEachFace));
width >>= 1;
height >>= 1;
@@ -224,11 +359,15 @@ private CubemapTexture AllocateCubeMap(Stream stream, int width, int hei
return cubeMapTexture;
}
- private static MipMap ReadFaceTexture(Stream stream, int width, int height, TBlock blockFormat, uint dataForEachFace)
+ private MipMap ReadFaceTexture(Stream stream, int width, int height, TBlock blockFormat, uint dataForEachFace)
where TBlock : struct, IBlock
{
byte[] faceData = new byte[dataForEachFace];
ReadTextureData(stream, faceData);
+
+ // Apply endianness conversion if needed
+ this.endianHandler.ConvertPixelData(faceData, this.KtxHeader.GlTypeSize);
+
return new MipMap(blockFormat, faceData, width, height);
}
@@ -260,12 +399,16 @@ private MipMap[] ReadMipMaps(Stream stream, int width, int height, uint
var blockFormat = default(TBlock);
var mipMaps = new MipMap[count];
+
for (int i = 0; i < count; i++)
{
var pixelDataSize = this.ReadTextureDataSize(stream);
byte[] mipMapData = new byte[pixelDataSize];
ReadTextureData(stream, mipMapData);
+ // Apply endianness conversion if needed
+ this.endianHandler.ConvertPixelData(mipMapData, this.KtxHeader.GlTypeSize);
+
mipMaps[i] = new MipMap(blockFormat, mipMapData, width, height);
width >>= 1;
@@ -292,9 +435,7 @@ private uint ReadTextureDataSize(Stream stream)
throw new TextureFormatException("could not read texture data length from the stream");
}
- var pixelDataSize = BinaryPrimitives.ReadUInt32LittleEndian(this.buffer);
-
- return pixelDataSize;
+ return this.endianHandler.ReadUInt32(this.buffer);
}
}
}
diff --git a/src/ImageSharp.Textures/Formats/Ktx/NativeEndianHandler.cs b/src/ImageSharp.Textures/Formats/Ktx/NativeEndianHandler.cs
new file mode 100644
index 00000000..3f0c6fc0
--- /dev/null
+++ b/src/ImageSharp.Textures/Formats/Ktx/NativeEndianHandler.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System;
+using System.Buffers.Binary;
+
+namespace SixLabors.ImageSharp.Textures.Formats.Ktx
+{
+ ///
+ /// Handles endianness when file endianness matches system endianness (no conversion needed).
+ ///
+ internal sealed class NativeEndianHandler : IEndianHandler
+ {
+ private readonly bool isLittleEndian;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Whether the file is little-endian.
+ public NativeEndianHandler(bool isLittleEndian)
+ {
+ this.isLittleEndian = isLittleEndian;
+ }
+
+ ///
+ public uint ReadUInt32(ReadOnlySpan buffer)
+ {
+ return this.isLittleEndian
+ ? BinaryPrimitives.ReadUInt32LittleEndian(buffer)
+ : BinaryPrimitives.ReadUInt32BigEndian(buffer);
+ }
+
+ ///
+ public void ConvertPixelData(Span data, uint typeSize)
+ {
+ // No conversion needed when endianness matches
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Formats/Ktx/SwappingEndianHandler.cs b/src/ImageSharp.Textures/Formats/Ktx/SwappingEndianHandler.cs
new file mode 100644
index 00000000..36ac8f4d
--- /dev/null
+++ b/src/ImageSharp.Textures/Formats/Ktx/SwappingEndianHandler.cs
@@ -0,0 +1,77 @@
+// Copyright (c) Six Labors.
+// Licensed under the Six Labors Split License.
+
+using System;
+using System.Buffers.Binary;
+
+namespace SixLabors.ImageSharp.Textures.Formats.Ktx
+{
+ ///
+ /// Handles endianness when file endianness differs from system endianness (requires byte swapping).
+ ///
+ internal sealed class SwappingEndianHandler : IEndianHandler
+ {
+ private readonly bool isLittleEndian;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Whether the file is little-endian.
+ public SwappingEndianHandler(bool isLittleEndian)
+ {
+ this.isLittleEndian = isLittleEndian;
+ }
+
+ ///
+ public uint ReadUInt32(ReadOnlySpan buffer)
+ {
+ return this.isLittleEndian
+ ? BinaryPrimitives.ReadUInt32LittleEndian(buffer)
+ : BinaryPrimitives.ReadUInt32BigEndian(buffer);
+ }
+
+ ///
+ public void ConvertPixelData(Span data, uint typeSize)
+ {
+ if (typeSize == 2)
+ {
+ SwapEndian16(data);
+ }
+ else if (typeSize == 4)
+ {
+ SwapEndian32(data);
+ }
+ }
+
+ ///
+ /// Swaps endianness for 16-bit values in-place.
+ ///
+ /// The data to swap.
+ private static void SwapEndian16(Span data)
+ {
+ for (int i = 0; i < data.Length; i += 2)
+ {
+ byte temp = data[i];
+ data[i] = data[i + 1];
+ data[i + 1] = temp;
+ }
+ }
+
+ ///
+ /// Swaps endianness for 32-bit values in-place.
+ ///
+ /// The data to swap.
+ private static void SwapEndian32(Span data)
+ {
+ for (int i = 0; i < data.Length; i += 4)
+ {
+ byte temp0 = data[i];
+ byte temp1 = data[i + 1];
+ data[i] = data[i + 3];
+ data[i + 1] = data[i + 2];
+ data[i + 2] = temp1;
+ data[i + 3] = temp0;
+ }
+ }
+ }
+}
diff --git a/src/ImageSharp.Textures/Formats/Ktx2/Ktx2DecoderCore.cs b/src/ImageSharp.Textures/Formats/Ktx2/Ktx2DecoderCore.cs
index 6decbef3..0942b48a 100644
--- a/src/ImageSharp.Textures/Formats/Ktx2/Ktx2DecoderCore.cs
+++ b/src/ImageSharp.Textures/Formats/Ktx2/Ktx2DecoderCore.cs
@@ -69,6 +69,7 @@ public Texture DecodeTexture(Stream stream)
int width = (int)this.ktxHeader.PixelWidth;
int height = (int)this.ktxHeader.PixelHeight;
+ // Level indices start immediately after the header
var levelIndices = new LevelIndex[this.ktxHeader.LevelCount];
for (int i = 0; i < levelIndices.Length; i++)
{
@@ -84,15 +85,39 @@ public Texture DecodeTexture(Stream stream)
var ktxProcessor = new Ktx2Processor(this.ktxHeader);
+ Texture texture;
if (this.ktxHeader.FaceCount == 6)
{
- CubemapTexture cubeMapTexture = ktxProcessor.DecodeCubeMap(stream, width, height, levelIndices);
- return cubeMapTexture;
+ texture = ktxProcessor.DecodeCubeMap(stream, width, height, levelIndices);
+ }
+ else
+ {
+ var flatTexture = new FlatTexture();
+ MipMap[] mipMaps = ktxProcessor.DecodeMipMaps(stream, width, height, levelIndices);
+ flatTexture.MipMaps.AddRange(mipMaps);
+ texture = flatTexture;
}
- var texture = new FlatTexture();
- MipMap[] mipMaps = ktxProcessor.DecodeMipMaps(stream, width, height, levelIndices);
- texture.MipMaps.AddRange(mipMaps);
+ // Seek to the end of the file to ensure the entire stream is consumed.
+ // KTX2 files use byte offsets for mipmap data, so the stream position may not
+ // be at the end after reading. We need to find the furthest point read.
+ if (levelIndices.Length > 0)
+ {
+ long maxEndPosition = 0;
+ for (int i = 0; i < levelIndices.Length; i++)
+ {
+ long endPosition = (long)(levelIndices[i].ByteOffset + levelIndices[i].UncompressedByteLength);
+ if (endPosition > maxEndPosition)
+ {
+ maxEndPosition = endPosition;
+ }
+ }
+
+ if (stream.CanSeek && stream.Position < maxEndPosition)
+ {
+ stream.Position = maxEndPosition;
+ }
+ }
return texture;
}
diff --git a/src/ImageSharp.Textures/Formats/Ktx2/Ktx2Processor.cs b/src/ImageSharp.Textures/Formats/Ktx2/Ktx2Processor.cs
index 4dbd30d2..a6c7be1d 100644
--- a/src/ImageSharp.Textures/Formats/Ktx2/Ktx2Processor.cs
+++ b/src/ImageSharp.Textures/Formats/Ktx2/Ktx2Processor.cs
@@ -60,6 +60,8 @@ public MipMap[] DecodeMipMaps(Stream stream, int width, int height, LevelIndex[]
return AllocateMipMaps(memoryStream, width, height, levelIndices);
case VkFormat.VK_FORMAT_R16_SFLOAT:
return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_R32_SFLOAT:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
case VkFormat.VK_FORMAT_R8G8_UNORM:
case VkFormat.VK_FORMAT_R8G8_SNORM:
case VkFormat.VK_FORMAT_R8G8_UINT:
@@ -98,6 +100,10 @@ public MipMap[] DecodeMipMaps(Stream stream, int width, int height, LevelIndex[]
return AllocateMipMaps(memoryStream, width, height, levelIndices);
case VkFormat.VK_FORMAT_R32G32B32A32_SFLOAT:
return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_B10G11R11_UFLOAT_PACK32:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_E5B9G9R9_UFLOAT_PACK32:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
case VkFormat.VK_FORMAT_B8G8R8_UNORM:
case VkFormat.VK_FORMAT_B8G8R8_SNORM:
case VkFormat.VK_FORMAT_B8G8R8_UINT:
@@ -157,6 +163,48 @@ public MipMap[] DecodeMipMaps(Stream stream, int width, int height, LevelIndex[]
return AllocateMipMaps(memoryStream, width, height, levelIndices);
case VkFormat.VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK:
return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_4x4_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_4x4_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_5x4_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_5x4_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_5x5_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_5x5_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_6x5_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_6x5_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_6x6_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_6x6_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_8x5_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_8x5_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_8x6_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_8x6_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_8x8_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_8x8_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_10x5_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_10x5_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_10x6_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_10x6_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_10x8_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_10x8_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_10x10_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_10x10_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_12x10_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_12x10_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_12x12_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_12x12_SRGB_BLOCK:
+ return AllocateMipMaps(memoryStream, width, height, levelIndices);
}
throw new NotSupportedException("The pixel format is not supported");
@@ -229,6 +277,10 @@ public CubemapTexture DecodeCubeMap(Stream stream, int width, int height, LevelI
return AllocateCubeMap(stream, width, height, levelIndices);
case VkFormat.VK_FORMAT_R32G32B32A32_SFLOAT:
return AllocateCubeMap(stream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_B10G11R11_UFLOAT_PACK32:
+ return AllocateCubeMap(stream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_E5B9G9R9_UFLOAT_PACK32:
+ return AllocateCubeMap(stream, width, height, levelIndices);
case VkFormat.VK_FORMAT_B8G8R8_UNORM:
case VkFormat.VK_FORMAT_B8G8R8_SNORM:
case VkFormat.VK_FORMAT_B8G8R8_UINT:
@@ -286,6 +338,48 @@ public CubemapTexture DecodeCubeMap(Stream stream, int width, int height, LevelI
return AllocateCubeMap(stream, width, height, levelIndices);
case VkFormat.VK_FORMAT_ETC2_R8G8B8_UNORM_BLOCK:
return AllocateCubeMap(stream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_4x4_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_4x4_SRGB_BLOCK:
+ return AllocateCubeMap(stream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_5x4_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_5x4_SRGB_BLOCK:
+ return AllocateCubeMap(stream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_5x5_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_5x5_SRGB_BLOCK:
+ return AllocateCubeMap(stream, width, height, levelIndices);
+ case VkFormat.VK_FORMAT_ASTC_6x5_UNORM_BLOCK:
+ case VkFormat.VK_FORMAT_ASTC_6x5_SRGB_BLOCK:
+ return AllocateCubeMap