Sign in

iiinova is part of the Atmosphere — an open, public network where you own your identity.

Ignacio Castaño🍉

@castano.mastodon.gamedev.place.ap.brid.gy
80 followers 3 following 314 posts

Master swimmer, mountain climber, and stand up coder. I work on texture processing tools at Ludicon. Formerly at Roblox, Thekla (The Witness), NVIDIA […] 🌉 bridged from ⁂ mastodon.gamedev.place/@castano, follow @ap.brid.gy to interact

PostsRepliesMedia
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 1h
Very pleased with the latest results of the ETC2 codec. I'm surprised Spark now scores better than all other codecs, except CVTT. It clearly falls behind on some some images, where you really need T/H modes and an exhaustive table selection. I'm […] [Original post on mastodon.gamedev.place]
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 15/10/2026
Revisiting the ETC codec. Tuning against @EricLasota's CVTT has proven useful to identify the largest gaps between what Spark achieves and what is possible in the format. Turns out I had some small bugs that were degrading quality. I also bisected the […] [Original post on mastodon.gamedev.place]
ssimu2 chart comparing Spark 1.4 vs Spark 1.5
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 13/10/2026
Tuning the Spark kernels for PS5 I was able to get up to a 59% performance improvement. I may need to put together another release soon!
020 2
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 10/10/2026
I've written a brief retrospective of the last 4 years, with my thoughts on middleware, AI, and texture compression. I consider this a first draft, would love to hear your feedback before sharing more widely. www.ludicon.com/castano/blog/2026/1…
ludicon.com
4 years of Ludicon
Last weekend marked the four-year anniversary of Ludicon. I started Ludicon because I believed that working independently would let me do my best work. Building software on my own, I can move fast, take risks, and focus on problems others don’t consider practical, or slow down and go deep on rabbit holes without having to justify myself. I’m happy with the quality of the work I’ve produced, excited about what is yet to come, but disappointed with its commercial success so far. In retrospect, 2022 was not a particularly good time to start a game middleware business. In that year alone the game industry saw 8,500 layoffs. That trend would continue getting worse and has not stopped yet. Middleware had been in a steady decline. Unity, Unreal and others offered complete engines with no upfront costs. The question was no longer whether a third-party component was better than what you’d write yourself, but whether it was better enough to justify replacing what the engine already provides. It rarely was. Epic’s acquisition of RAD in 2021 marked the end of that era: the last major independent vendor of core runtime technology was now part of an engine. By the time I started working on Spark in 2022, engine consolidation had won. Against that backdrop, the business case was ambiguous, but my hope was that with reduced headcounts, long-term research into core technology would slow down and outsourcing that technology would become more attractive. When I started working on Ludicon I believed that there would be a growing market for texture compression. Games were only getting larger, fidelity targets kept increasing, and textures were often the assets with the largest footprint. At the same time, an increasing amount of content was generated by players themselves. This created an explosion of content, and with it the need to compress at runtime. What I did not anticipate was AI. Generative tools lower the barriers to content creation. At the same time, the AI buildout has created shortages and driven up the price of memory and storage. Existing devices are staying in use longer, and new devices often ship with less memory than they did a few years ago. Both pressures push in the same direction: more content, less room for it. Over the last year AI has also changed the software industry. This transformation has been so fast that it induced vertigo. At some point, I wondered if my job would be at risk. Could LLMs produce competitive texture codecs on their own? I tried and they were nowhere close. Still, AI has transformed the way I code, but not the way you would expect. For the most part I still write the codecs by hand, the old fashioned way. AI just lets me do that more efficiently. AI reviews my changes and it routinely catches bugs that would have taken hours to debug. It has allowed me to build tools that feel like having superpowers. For example, I can edit one shader and automatically display statistics and annotated assembly listings on all the platforms that I support. When a customer reports a bug or issue, I just plug in the relevant device and let an agent build a repro and suggest a fix for the problem. When a CI build fails, an agent automatically fixes it and opens a PR. The validation, CI, and regression testing tools I have today are better than what I would have imagined possible as a solo developer. This has allowed me to stay focused on the problems that I enjoy working on and where I can make a difference. After Epic’s acquisition of RAD I though there would be a need for an independent technology provider, and I hoped I could fill that vacuum. Filling it with another RDO encoder meant competing with an established product built by some of the brightest developers I know. I had written RDO encoders before and I concluded it was a race I could not win on quality or performance. In all likelihood I would be forced to compete on price. Real-time compression was more interesting. It solved the problem of content created at runtime, and it opened a different solution space for storage and transmission. Traditional RDO block encoders are constrained by the fixed-rate block structure and do not scale to low bitrates. With real-time compression, the intermediate representation can be anything, as long as it can be encoded into a GPU-friendly format at load time. I still think this is the right direction. Modern image formats like WebP and AVIF, combined with Spark, already deliver much better compression ratios than RDO block encoders. However, they suffer from slow decompression performance. In the next year or so I predict there will be new codecs with competitive quality and much higher decoding throughput. The recent work on neural textures and learned codecs will widen the space further. Four years in, and I think my bet looks better than it did in 2022.
2102 10
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 09/10/2026
Something I worked on was released today. It looks great! The visuals haven't changed much since I left, but it seems to be holding up. I'm very proud of the work we did and sorry the team didn’t get to finish it together, all of us left when Thekla stopped being a good place to work.
150 5
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 08/10/2026
Well, I just filed a trademark application for Spark, which I guess I should have done ages ago. The moment I file it and my phone number goes into the public record I’m bombarded with scam calls. Yikes.
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 30/09/2026
Lowering occupancy is not always the answer. I was able to double throughput by artificially increasing occupancy in order to limit the number of threads that can run concurrently. In this case more threads just caused L1 thrashing. For best performance you have to size the dispatch so that the […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 25/09/2026
New blog post! Encoding render targets using Tile Shaders: www.ludicon.com/castano/blog/2026/0…
ludicon.com
Encoding Render Targets for Free with Tile Shaders
The _Spark_ codecs are often bandwidth-bound. The codec usually reads an uncompressed RGBA8 texture and outputs a block-compressed texture that, depending on the format, is 2–8 times smaller. Reading the input is what dominates bandwidth. When bandwidth-bound, execution stalls waiting for input data, while the GPU tries to hide that latency by overlapping loads for some blocks with computation on others. Mobile GPUs render one tile at a time and keep its data in on-chip memory to save bandwidth and power. Wouldn’t it be great if our codecs could also use this tile memory as their input? That’s exactly what Metal tile shaders allow us to do. When I started working on _Spark_ I was very excited about the possibilities of using tile shaders, but when I tried them on an iPhone 12 and M1 MacBook Pro the results were underwhelming. There seemed to be scheduling issues that caused the combined fragment + tile shader to run at a fraction of the speed of the individual shaders. The difficulties were understandable: On Apple GPUs a tile is typically 16×16 to 32×32 pixels, and to encode it we use a single thread for each 4×4 block. The tile dispatch has to wait for that tile’s fragment work to finish, and 16–64 threads is a small threadgroup to hide latency with. In most cases the fragment shader is relatively simple and has high occupancy, while the encoder is much more complex and tends to have lower occupancy. Scheduling that work effectively seemed quite challenging, so my initial excitement faded. However, a few months ago some Apple folks mentioned that the performance of tile shaders had improved in recent OS versions. I didn’t have high hopes, but I tested it again on a wider array of devices and to my surprise the results were much better. This could be attributed to many factors: improvements in the codecs, updated compiler, better testing methodology, different architectures, and OS updates. Today the tile path is 1.2–1.7× faster than a separate compute pass, and on most of the iPhones it’s as fast as or faster than writing to the uncompressed texture, essentially making real-time texture encoding free. The benefits of running the codecs in the tile shader do not end there. Lower bandwidth results in lower power consumption, and the uncompressed render target is never stored, so it can be memoryless, saving the allocation entirely. ## How to use tile shaders? In order to use the tile shader in Metal you need a device with at least an Apple4 GPU (A11) or later, running on iOS 11 or macOS 11. ### 1. The encode kernel Include the single-header _Spark_ codec and write a tile kernel. Each thread reads one 4×4 block from the `imageblock` and encodes it: #define SPK_ENABLE_FP16 1 #define SPK_ENABLE_INT16 1 #include "spark_astc.metalh" struct ColorData { half4 color [[color(0)]]; }; kernel void encode_tile(imageblock<ColorData> img, ushort2 tile_coord [[thread_position_in_threadgroup]], ushort2 gid [[thread_position_in_grid]], ushort2 grid_blocks [[threads_per_grid]], device uint4* output [[buffer(0)]]) { half3 block[16]; for (ushort y = 0; y < 4; y++) for (ushort x = 0; x < 4; x++) block[4 * y + x] = img.read(4 * tile_coord + ushort2(x, y)).color.rgb; uint idx = uint(gid.y) * grid_blocks.x + gid.x; output[idx] = spark_encode_astc_4x4_rgb(block, /*quality=*/1); } ### 2. Allocate a memoryless render target The render pass needs a color attachment, but its contents never leave tile memory, so use the `MTLStorageModeMemoryless` storage mode to avoid allocating memory for it: MTLTextureDescriptor* d = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA8Unorm width:w height:h mipmapped:NO]; d.storageMode = MTLStorageModeMemoryless; d.usage = MTLTextureUsageRenderTarget; id<MTLTexture> tile_target = [device newTextureWithDescriptor:d]; ### 3. Set up the tile pipeline MTLTileRenderPipelineDescriptor* td = [MTLTileRenderPipelineDescriptor new]; td.tileFunction = [library newFunctionWithName:@"encode_tile"]; // same format as the attachment td.colorAttachments[0].pixelFormat = MTLPixelFormatRGBA8Unorm; // one thread per 4x4 block, not per pixel td.threadgroupSizeMatchesTileSize = NO; id<MTLRenderPipelineState> tile_pso = [device newRenderPipelineStateWithTileDescriptor:td options:0 reflection:nil error:&error]; ### 4. Render, then encode, in one pass Draw your content as usual, then dispatch the tile kernel in the same render encoder: MTLRenderPassDescriptor* rp = [MTLRenderPassDescriptor renderPassDescriptor]; rp.colorAttachments[0].texture = tile_target; rp.colorAttachments[0].loadAction = MTLLoadActionDontCare; rp.colorAttachments[0].storeAction = MTLStoreActionDontCare; // required for a memoryless target id<MTLRenderCommandEncoder> enc = [cb renderCommandEncoderWithDescriptor:rp]; // ... draws that fill the color attachment ... [enc setRenderPipelineState:tile_pso]; [enc setTileBuffer:output_buffer offset:0 atIndex:0]; [enc dispatchThreadsPerTile:MTLSizeMake(enc.tileWidth/4, enc.tileHeight/4, 1)]; [enc endEncoding]; You can force a specific tile size `rp.tileWidth` and `rp.tileHeight`, not specifying them let’s the driver choose it automatically, and you can query the chosen values with `enc.tileWidth` and `enc.tileHeight`. The tile dispatch runs after all earlier draws in the render pass have finished and it processes the final pixels. A full example showcasing this is included in the Spark 1.4 SDK. ## What are the results? To benchmark this I generated the render target with three different shaders: a math-heavy procedural texture, a minimal shader with just a simple gradient, and terrain-splatting shaders like those used in virtual texture pipelines. They all behaved similarly, but the gains were largest on the terrain example, which is also the most realistic scenario, so it’s the one I’m presenting here. The following are timings in milliseconds of rendering a 1024×1024 terrain texture and encoding it to ASTC 4×4 at medium quality (Q1). Device| OS| Uncompressed| Compute| Tile| Tile vs Compute ---|---|---|---|---|--- iPhone 8 – A11| iOS 16.7.16| 0.839| 1.184| 0.834| 1.42x iPhone 12 – A14| iOS 26.6.2| 0.470| 0.782| 0.567| 1.38x iPhone 15 Pro – A17| iOS 26.6.1| 0.311| 0.497| 0.301| 1.65x iPhone 16 – A18| iOS 18.7.3| 0.332| 0.477| 0.317| 1.50x M4 MacBook Pro| macOS 15.7.7| 0.073| 0.119| 0.098| 1.21x To get stable measurements I ran each test 256 times and averaged the results. I also changed the GPU performance state to medium in order to obtain more stable clocks, so these results are not representative of peak performance. On every device I measured, the tile path beats the separate compute pass by 1.2–1.7×. On three of the four iPhones (A11, A17, A18), producing the compressed texture in the Tile Shader is as fast or faster than writing the uncompressed one. The MacBook Pro benefits less, probably because it has more bandwidth and larger caches, so the uncompressed store is not the bottleneck. It’s unclear why the A14 lags behind. The following diagram illustrates where the time goes (box lengths are approximate, not to scale): Uncompressed 4 B / pixel Generate texture 4 B write Render + compute 11 B / pixel Generate texture 4 B write Encode texture 4 B read + 1 B write Copy to texture 1 B read + 1 B write Tile shader 3 B / pixel Generate + encode 1 B write Copy to texture 1 B read + 1 B write The compute path first writes the uncompressed RGBA8 texture (4 B/pixel), reads it back (4 B/pixel), writes the ASTC output to a buffer (1 B/pixel), and finally copies that buffer into the compressed texture (another 2 B/pixel for the read and write): 11 bytes of memory traffic per pixel. The tile path eliminates the uncompressed write and read entirely. It encodes directly from tile memory, leaving only the compressed output and its final copy: 3 bytes per pixel. That’s even less traffic than simply writing the 4-byte-per-pixel uncompressed render target, which explains how rendering and encoding together can be as fast as rendering alone. These numbers exclude texture sampling, which is identical across all three paths. I double-buffered the output buffers and render targets so the copy overlaps with the next pass. Writing directly to the compressed texture through heap aliasing, as described in this earlier post, would remove the copy and narrow the gap to uncompressed further, but it isn’t officially supported, so I didn’t use it here. ## Conclusions Tile shaders make runtime texture compression considerably more attractive on Apple GPUs. Instead of rendering an uncompressed texture to memory and reading it back for compression, _Spark_ can encode directly from tile memory. That means the usual benefits of compressed textures (lower memory use, lower bandwidth, and faster subsequent rendering) can be obtained without paying a significant runtime encoding cost. Tile shaders are not exclusive to Apple hardware. They are also available on Qualcomm GPUs under Vulkan through the VK_QCOM_tile_shading extension. I haven’t yet experimented with it, but I will as soon as I can get my hands on a device supporting it.
011 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 17/09/2026
OK, Spark 1.4 is finally out! The highlight is HDR: new BC6H and ASTC HDR codecs, offline quality at real-time speeds. Also: BC1 Ultra, a BC7 codec for packed material maps, ASTC 5×5, and broader device support. www.ludicon.com/castano/blog/2026/0…
ludicon.com
Spark 1.4
I’m excited to announce the release of _Spark_ 1.4. _Spark_ is a set of real-time GPU texture codecs that produce results competitive with offline encoders while running thousands of times faster, on every GPU from budget phones to high-end PCs and consoles. The highlight of this release is HDR. _Spark_ 1.4 adds two new HDR codecs, **BC6H** for desktop and consoles and **ASTC HDR** for mobile, and both deliver quality that matches or exceeds the best offline encoders at real-time speeds. Real-time HDR compression opens up a new range of applications: * **Light probes and reflection maps** that are re-rendered as the scene changes and compressed on the fly. * **Dynamic lightmaps** for time-of-day cycles and destructible environments. * **Dynamic skies or any other HDR render target** whose contents can be cached across frames. * **HDR images** decoded at runtime, from EXR, AVIF or JPEG/R, without the need for an offline pipeline. **What’s new in Spark 1.4:** * **New HDR codecs** : BC6H and ASTC HDR. With offline quality at real-time performance. * **BC1 Ultra** : a new quality level for BC1 that surpasses the perceptual quality of offline encoders at a fraction of their cost. * **BC7 NRM** : a new BC7 codec for packed material maps that store normals, roughness and metalness in a single texture. * **ASTC 5×5** : a new block size that fills the gap between our 4×4 and 6×6 codecs. * **Broader compatibility** : PowerVR Series D (Pixel 10), Vulkan 1.0 devices, improved GLES `mediump` support, and improved PlayStation 5 support. This release also includes a new Getting Started guide and Performance Guidelines, many new examples, improved regression testing, bug fixes, and many other small quality and performance improvements. It’s exciting to see _Spark_ gaining momentum and shipping in a wide range of games and applications. Check out our front page for the latest additions to our client list. ## HDR Codecs Until now, _Spark_ handled HDR content through our RGBM codecs, which pack an HDR range into an LDR format. RGBM works everywhere, but it requires a decode in the shader and its range is limited. The two new codecs target the native HDR formats instead: the compressed texture is sampled directly and decodes to 16-bit floats, so there’s no color transform and no shader changes on the sampling side. ### BC6H Our previous codecs deliberately target a subset of their format: the modes and partitions that give the best quality per cycle within a real-time budget. The new BC6H codec breaks from that strategy. It is our first codec that can target the _entire_ format: all 14 modes and all 32 partition shapes. Which subset of the format the codec targets is configured at compile time, and we provide four presets. At the lowest level, the codec saturates memory bandwidth just like our other real-time codecs. At the highest level, it produces results that challenge the best offline encoders while running orders of magnitude faster. This chart shows the SSIMULACRA2 score of each encoder against its throughput on a M4 MacBook Pro, over a set of 27 HDR light probes, lightmaps and photographs. Larger scores are better. _Spark_ at its highest quality level scores above every other encoder, and nearly as high as OodleTex, while running 400× faster. Compared to the existing real-time BC6H encoders, the picture is even clearer: What I find most remarkable is that these results are achieved with a single candidate encoding. There’s no error evaluation and no comparison between multiple candidate encodings: the codec chooses a mode and a partition up front and never looks back. This is what enables these codecs to run well on the GPU. Compared with Krzysztof Narkowicz’s codec, _Spark_ delivers comparable performance while avoiding some of its worst-case artifacts, as the following image comparisons show. Spark Q1 Spark Q2 Narkowicz Q1 Narkowicz Q2 88.262 Spark Q1 Spark Q2 Narkowicz Q1 Narkowicz Q2 88.262 ### ASTC HDR One of the surprises of this release is the new ASTC HDR codec. I did not plan to include it, but the initial results were so promising that I could not stop myself. This first version is intentionally simple: it uses all the HDR endpoint encoding modes, but a single partition and a single weight and endpoint bit distribution. With these constraints I did not expect great results, but it turns out that the algorithms I developed for BC6H also work exceptionally well with ASTC, and even this first version matches or exceeds the perceptual quality of the existing offline encoders. At its highest quality level, _Spark_ scores above Arm’s astcenc at its most exhaustive setting and above every UASTC HDR preset except the slowest, while encoding on the GPU thousands of times faster than these CPU encoders on the same machine. The medium quality level matches astcenc’s at the default setting. The existing ASTC HDR encoders minimize the mean squared error in the linearized logarithmic space of the format. As a result, they often spend bits on features that are not perceptually noticeable while under-allocating elsewhere, and that results in errors that are visible with the naked eye. _Spark_ is optimized for perceptual metrics instead: Spark Arm Basis 86.620 Spark Arm Basis 86.620 Spark Arm Basis 86.620 Spark Arm Basis 86.620 As far as I know this is the only real-time ASTC HDR encoder publicly available, so there’s nothing else to compare it against on mobile. Here are the three quality levels on a Pixel 8 (Mali-G715): A 1024×512 light probe compresses in 0.5 ms at the highest quality level, and 0.17 ms at the lowest. Like all of our codecs, I’ve taken special care to ensure the code is fully vectorized for 16-bit arithmetic, and that the resulting code also performs well on Adreno. Even though Adreno does not support packed math instructions, in practice the vectorized variants result in less register shuffling, increased occupancy, and a reduced instruction footprint, so the same shader works well on every mobile GPU. Some mobile devices support both ASTC HDR and BC6H. Our BC6H codec was developed with PC and consoles in mind, but at the lower quality levels it also runs great on mobile. The following chart shows both codecs on an Adreno 740: ## BC1 Ultra BC1 is the oldest block compression format we support and the one most encoders have had the most time to mature, so I did not expect much headroom. The new _Ultra_ quality level proved me wrong. Modern techniques combined with perceptual quality optimizations result in higher quality than the existing offline encoders, and it still runs orders of magnitude faster than them. Three things make the difference: the use of the 3-color mode for blocks with near-black texels, a novel endpoint quantization procedure that takes into account the residual quadric of the least squares solver, and a greedy endpoint refinement. At this quality level it is very difficult to see any difference. The results are nearly indistinguishable from the offline codecs. ## BC7 NRM Our existing BC7 codecs are optimized for color images, but BC7 textures are often used to store other material attributes. One of the most common arrangements is to pack two components of the normal in the RG channels and roughness and metalness in the BA channels. Our color codecs were doing a reasonable job on this kind of data, but it was clear we could do better by tailoring the coding choices to it. Our new codec is optimized for the output of typical shading models, with special attention to smooth normals on glossy surfaces, where a small error in the normal becomes artifacts in the specular highlight. This codec has mostly been tuned with client assets that I’m not able to share publicly, but at least I can share some numerical results. The chart below measures the quality of the reconstructed normals with SSIM, which correlates much better with the appearance of the shaded surface than the raw error. ## ASTC 5×5 Targeting large block sizes in GPU encoders is difficult due to the high register pressure. _Spark_ 1.2 introduced our first streaming codec with support for ASTC 6×6 blocks. This codec processes one texel at a time, so it does not need to keep all the block colors in registers at the same time. This release refines that strategy and extends it to the 5×5 block size, offering a compression ratio in between the 4×4 and 6×6 codecs and giving developers a finer-grained quality and size tradeoff. ASTC 4×4 ASTC 5×5 ASTC 6×6 ASTC 4×4 ASTC 4×4 ASTC 5×5 ASTC 6×6 ASTC 4×4 ASTC 4×4 ASTC 5×5 ASTC 6×6 ASTC 4×4 Larger blocks reduce bandwidth, but not necessarily the memory footprint. See our previous investigation for the details: The True Size of ASTC Textures. ## Broader Compatibility We continue expanding compatibility in both directions: to new devices as they come to market, such as the Pixel 10 and 11, and to legacy devices, such as Vulkan 1.0 hardware, that we had previously thought non-viable. ### PowerVR Series D I’m excited to see PowerVR making a comeback in Android phones with the Series D GPUs in the Pixel 10. I’m happy to report that _Spark_ runs great on this new hardware, and that some outstanding issues from previous devices appear to be fixed and no longer require cumbersome workarounds. Series D is also the first PowerVR generation with ASTC HDR support, so the new codec runs there too. ### Vulkan 1.0 This release adds support for many Vulkan 1.0 devices that I had previously written off as non-viable. This includes many early Arm devices, such as the Galaxy S8, as well as some Adrenos, such as the Adreno 618 in the Pixel 3a. In the past, bugs unrelated to the codecs themselves had prevented my examples, demos and benchmarks from running on these devices, and I had assumed Vulkan 1.0 was simply too unstable to be practical. On closer inspection, it turned out to be possible to work around these issues and prove that the codecs run fine on these devices. ### Improved GLES `mediump` support Improving `mediump` support on GLES mostly came down to tracking down and fixing bugs in the `spirv-opt` and `spirv-cross` toolchains, and those fixes have been contributed upstream (SPIRV-Tools #6690 , SPIRV-Cross #2633). ### PlayStation 5 The PlayStation 5 codecs have been through extensive testing and a round of optimizations, and the SDK now includes a PS5 example and integration guidelines with performance results for every codec. ## Final Words Earlier this year I released _Spark for Unity_, which brings the codecs to the Unity engine, and spark.js brings them to WebGL and WebGPU. Both will pick up the new codecs in a follow-up release, and the Android and iOS demos will be updated shortly. If you want the longer story behind this work, I recently appeared on the Wookash Podcast, where we talked about texture compression, _Spark_ , and much more. If you’d like to try the new codecs in your own engine, or for licensing inquiries and anything else, reach out at spark@ludicon.com.
012 2
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 14/09/2026
Some results of the upcoming ASTC HDR codec. Arm and Basis are at the highest quality leve, and on my M4 they run at 1.23 and 0.7 Mpix/sec respectively. Spark is a very simple codec, but it's optimized for perceptual quality. It scores as high as the […] [Original post on mastodon.gamedev.place]
002 2
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 12/09/2026
I thought one of the nice things about ASTC HDR is the limited number of devices I need to validate, since it's not available everywhere. However, it turns out that it's only 6 out ~30, so it's not much of a relief. Lots of minor precision issues across many devices. At least I don't have to […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 11/09/2026
This is ridiculously good. Now I just need to make it fast.
101 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 04/09/2026
I'm trying to get things ready for a new release of the Spark codecs. While testing the new BC6 codec on a wider range of images I run into some artifacts I had not seen before. Digging into it I came up with an understanding of the problem and some […] [Original post on mastodon.gamedev.place]
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 02/09/2026
This is exciting: github.com/gpuweb/gpuweb/issues/200… A WebGPU feature/extension that I proposed has finally been accepted! 🎉 It adds support for block compressed textures that are not aligned to the block size. Most APIs support this, except D3D11 and early […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
310 3
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 31/08/2026
Hobbit Book did not disappoint (the climb, not the book)
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 29/08/2026
The joys of adding support for a new platform. Why are you doing this compiler?
Small pixel artifacts on an encoded texture.
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 27/08/2026
RE: mastodon.social/@verge/117168931003… I wonder if I should try to capitalize on this and how. Spark is one of the easiest ways to reduce your app memory footprint if you have any uncompressed textures lying around. However, it seems to me the average developer doesn't care […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 23/07/2026
Someone mentioned isocharts today (Microsoft's UVAtlas). I remember evaluating it on The Witness, back when it was closed source and not being happy with the results. I just gave it another try and it doesn't seem to work particularly well.
remeshed craftsman uv-mapped with Microsof'ts uvatlas (isocharts).
120 2
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 22/07/2026
Hi Siggraph!
Me in front of the siggraph sign
010 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 12/07/2026
My favorite swimming hole
041 4
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 11/07/2026
I'll be in LA next week! Not for SIGGRAPH, but for my daughter's water polo tournament. I'll have Wed/Thu (July 22–23) open to meet clients and partners while I'm there. Might also grab a SIGGRAPH day pass if I'm not too busy. Reach out if you want to connect.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 08/07/2026
The relief mapping on the snow looked really fake.
Sun cups on the snow field below Lamarck col.
121 2
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 07/07/2026
In my happy place
010 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 29/06/2026
My friend Art is a mountain ultrarunner. He got injured training for a 120 mile race. His abs got detached from his pelvis while running downhill. Despite of that he can still hike and he leaves me in the dust.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 22/06/2026
I spent the last 10 days camping, hiking and backpacking in Yellowstone and the Tetons. It’s such an amazing place! and it was so nice to disconnect; I should do this more often.
Yellowstone backcountry camp with a Bison pacing in the background.
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 15/06/2026
What a disgrace
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 11/06/2026
I've published a repository with my ssimulacra2 implementation at: github.com/Ludicon/ic-metrics I use this to tune all my codecs and generate the distortion maps in ludicon.com/sparkjs/#Examples I shared this a while ago through a […] [Original post on mastodon.gamedev.place]
ssimulacra2 distortion map showing the combined error of AVIF and BC7 compression.
110 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 09/06/2026
The Spark for Unity demo is finally available on Google Play. ludicon.com/spark/#demo Approval took longer than I expected, but I also expected some kind of notification and it never materialized, so I only noticed now that the app was available on the store. #unity #gamedev
071 7
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 06/06/2026
Well, that was a surprise. I swam faster than the last three years, despite having a crappy night of sleep and not feeling particularly strong lately.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 06/06/2026
One more year as the race setup coordinator for our annual open water swim / fundraiser. What a pleasure with such a great group of volunteers. Very windy tonight, hoping all our hard work doesn’t blow away during the night!
Lake Berryessa
010 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 04/06/2026
I'm finally ready to announce Spark for Unity, a Unity package that brings the Spark texture codecs to the Unity engine and it's free for projects with lifetime revenue under $100K. www.ludicon.com/castano/blog/2026/0…
ludicon.com
Spark for Unity
Today I’m releasing _Spark for Unity_ , a Unity package that brings the _Spark_ texture codecs to the Unity engine. It’s available now on GitHub and it’s free for projects with lifetime revenue under $100K. Unity’s built-in runtime texture compression is CPU-bound, limited in format support, and often too slow for real-time use cases. _Spark for Unity_ brings GPU-native texture compression to Unity, making it practical to compress textures at runtime in real-time. Texture2D compressed = Spark.EncodeTexture(source, SparkFormat.RGB); That single line takes an existing texture and produces a standard `Texture2D` in a GPU-native format (BC7 or ASTC depending on the device), ready to assign to a material. It runs entirely on the GPU, at least an order of magnitude faster than Unity’s `Texture2D.Compress`, and produces higher quality results. The package works across all Unity render pipelines (URP, HDRP, and Built-in) without modification. It’s been tested on Metal (macOS, iOS), Vulkan (Android, Windows), OpenGL ES 3.1 (Android), and Direct3D (Windows), on Unity versions 6.3 to 6.6. If you want to see it running, the demo app is available for iOS, Android, macOS, and Windows. ## How _Spark_ compares to `Texture2D.Compress` Unity ships with a built-in runtime compressor, `Texture2D.Compress`. This is the natural choice for anyone compressing textures at runtime in Unity. However, the built-in compressor runs on the CPU, and uses a single thread. Texture compression is intrinsically parallel and therefore maps very well to the GPU. The GPU executes this work much more efficiently, and because it runs off the main thread, it doesn’t block frame execution. On GPUs that support it, _Spark_ can also run on the async compute queue, overlapping with rendering work and reducing the total frame cost further. Runtime-generated textures make the case even stronger. Source texture data is often generated in the GPU (mipmaps, procedural shaders, render targets, virtual texture tiles) and is always consumed by the GPU. Compressing this data on the CPU would require a read-back and upload which would dramatically reduce performance. Encoding on the GPU is the natural fit for the data flow. ### Supported Formats The compressed formats available through the built-in API are limited. Modern formats like ASTC and BC7 are not supported, and format selection is dictated by the target platform rather than the underlying device. | `Texture2D.Compress`| _Spark_ ---|---|--- R| BC4 / EAC_R| BC4 / EAC_R RG| BC5 / EAC_RG| BC5 / EAC_RG RGB| BC1 / ETC2| BC7 / ASTC / BC1 / ETC2 RGBA| BC3 / ETC2| BC7 / ASTC _Spark_ supports both high-quality (BC7, ASTC) and low-bitrate (BC1, ETC2) formats for RGB content, letting you choose based on memory and quality requirements. For RGBA, _Spark_ targets BC7 and ASTC exclusively. These formats are widely available, have the same memory footprint as BC3 and ETC2_RGBA, and produce higher quality results. Note that many iOS and Android devices now support BC formats. The built-in path never targets BC formats on those devices; it defaults to ETC2. _Spark_ , in contrast, selects the best format the device supports. ### Performance Encoding a 1024×1024 texture takes about 1 ms with _Spark_ on a budget Android device like the Galaxy A15, compared to over 10ms with the built-in encoder. On desktop and high-end mobile devices, performance is significantly higher. The chart below compares performance across desktop, high-end mobile, and budget mobile: The demo app includes a benchmark that runs the full comparison on your own device. ### Quality _Spark_ and the built-in encoder make different format choices, so a direct comparison isn’t as clean as the throughput chart. Here’s how each performs on the formats they share, measured as RMSE (lower is better) on an 8-bit scale. Note that _Spark_ ’s codecs are tuned for perceptual quality, which RMSE captures only partially. The visual examples later in the section are a more representative. **Desktop (BC) formats:** | Format| Texture2D.Compress| Spark ---|---|---|--- R| BC4| 3.96| 0.55 RG| BC5| 6.27| 1.89 RGB – lo| BC1| 4.91| 4.41 RGB – hi| BC7| N/A| 2.16 RGBA| BC3 / BC7| 3.60| 2.74 **Mobile (ETC / ASTC) formats:** | Format| Texture2D.Compress| Spark ---|---|---|--- R| EAC_R| 0.69| 0.78 RG| EAC_RG| 2.12| 2.49 RGB – lo| ETC2| 4.57| 4.58 RGB – hi| ASTC| N/A| 2.32 RGBA| ETC2 / ASTC| 4.29| 3.19 The mobile ETC2 codecs in Unity are competitive with _Spark’_ s, and slightly better on some single- and dual-channel formats. _Spark for Unity_ ships the lower-quality ETC variants by default to maximize throughput on mobile; higher-quality ETC encoders are available in the full _Spark_ codec suite, but the difference rarely makes a perceptual impact at runtime. The BC story is different. Unity’s built-in BC implementations produce visible artifacts on most content, while _Spark_ ’s output is much closer to the original: Unity built-in Spark Unity built-in Unity built-in Spark Unity built-in The tables also miss an important advantage. On many modern iOS and Android devices, _Spark_ can target BC formats directly, while Unity’s built-in compressor is restricted to ETC2. In practice this often means both higher quality and faster encoding on the same device. ## The Demo The demo focuses on two scenarios that exercise _Spark for Unity_ in different ways. **Procedural virtual texturing.** A simplified virtual texturing pipeline built around a single 16 MB compressed atlas and an indirection texture. The virtual texture itself is 1,048,576 × 1,048,576, far larger than would fit in memory without virtualization. As the camera moves, the system identifies which tiles are visible, evaluates the Mandelbrot fractal on the GPU to render them, compresses them with _Spark_ , and copies the results into free slots in the atlas. The indirection texture is updated to point each visible region at its newly-compressed tile. This isn’t a production virtual texturing engine, but the minimum pipeline that exercises the runtime-compression part of the problem. It demonstrates the use case: _Spark_ makes it practical to compress tiles on the same frame they’re generated, without leaving the GPU, and without significant impact to quality or performance. **Runtime texture transcoding.** Textures distributed as PNG or JPEG, placed in `StreamingAssets` so they bypass Unity’s build-time processing and ship as-is. At load time, the demo decodes them and uses _Spark_ to compress them to the appropriate GPU format. The same approach works for glTF assets: the demo loads them with glTFast and provides a custom image loader that compresses textures at load time. Full source code is in the GitHub repository. ## How it Works The basic technique behind GPU texture compression in Unity was demonstrated by Aras Pranckevičius in UnityGPUTexCompression: run the encoder in a compute shader, write the compressed blocks to a UINT `RenderTexture`, copy the `RenderTexture` into a `Texture2D` with the appropriate compressed format. _Spark for Unity_ follows the same steps, with support for more platforms and texture formats, and wraps everything in a simple C# API. The reason I hadn’t released a Unity integration before is that this approach requires the shaders to be distributed as source code. That’s fine for an open-source proof of concept, but not workable for _Spark_. Source code is only available to licensees. The solution is to ship the shaders as pre-compiled HLSL header libraries. The pipeline is: 1. Compile the codec shaders to a SPIR-V library with DXC. 2. Process the SPIR-V with `spirv-opt` to optimize the code. 3. Use SPIRV-Cross to translate the SPIR-V library to a single-file HLSL header library. You can drop the resulting `.hlsl` files into any Unity project and it just works. Unity translates the HLSL libraries to the appropriate native shader format for each target platform at build time, which is why the same library works across Vulkan, Metal, and Direct3D. This pipeline required changes to DXC’s SPIR-V compiler and to SPIRV-Cross. Some of those changes are already upstream in the KhronosGroup/SPIRV-Cross repository; others are still under review but stable enough for production use. ## Licensing _Spark for Unity_ is available under the same licensing terms as spark.js: * **Free** for projects with lifetime revenue under $100,000. * **Indie** ($1,000 one-time fee) for commercial projects with development budget under $1 million. * **Pro** ($10,000 one-time fee) for projects beyond that. All tiers include lifetime distribution rights. The C# code is released under the MIT license; the _Spark_ shaders are covered by the _Spark_ EULA. This release relaxes the free tier from “non-commercial only” to projects under $100K in lifetime revenue. The change applies to spark.js as well. The two products now share the same terms. Studios with custom pipeline requirements, console targets, source access, or production support needs can license the full _Spark_ codec suite under an enterprise agreement. See the _Spark_ licensing page for details. ## Try it _Spark for Unity_ is on GitHub at github.com/ludicon/spark-unity. To install via Unity Package Manager, add this Git URL to your project’s manifest: https://github.com/ludicon/spark-unity.git#v0.1.0 The basic API is a single call: Texture2D compressed = Spark.EncodeTexture(source, SparkFormat.RGB); For most use cases, that’s everything you need. The lower-level API exposes additional controls for advanced scenarios: batching multiple encodes, scheduling work on the async compute queue, updating individual mip levels or texture regions, and preallocating output textures to avoid per-frame allocations. See the README for more details. If you want to see Spark for Unity running before integrating it, the demo app is available for iOS, Android, macOS, and Windows. It includes the scenes shown above. ## What’s next? The same pre-compiled HLSL libraries that power _Spark for Unity_ are portable to other engines. Integrations for Unreal, Evergine, and others are within reach, and I’m interested in talking to anyone working in those ecosystems who’d want to collaborate. Working through the Unity integration also surfaced a number of cases where _Spark_ ’s performance is gated by Unity’s compute shader infrastructure rather than by the codecs themselves. Some of these are addressable with source code access; others require changes in Unity. I’m hoping to work with the Unity team on the ones that need their attention, and I’ll write up the technical details in a future post. _Spark for Unity_ is available today on GitHub. If you’re building apps with user-generated content, procedural textures, virtual texturing systems, or simply need better runtime texture compression, give it a try and let me know how it works for your project. If you’d like to talk in person, I’ll be at SIGGRAPH this summer.
110 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 04/06/2026
My kid is really into the silkson soundtrack, so we couldn’t miss this!
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 03/06/2026
So smooth! www.youtube.com/watch?v=mGip64OyygU
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 01/06/2026
NVIDIA RTX *Spark*? seriously? Sigh.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 30/05/2026
RE: mastodon.gamedev.place/@rygorous/11… What a treat to wake up a Saturday morning with a new blog post by @rygorous
mastodon.gamedev.place
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 28/05/2026
Well, we had a lice emergency at home, so no release tomorrow. We have spent the last couple of days doing lice checks, cleaning up, putting away stuffies, etc. The actual release is ready, but I still have to write the release announcement, and […] [Original post on mastodon.gamedev.place]
Mandelbrot texture compressed with Spark.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 27/05/2026
Also in the demo: procedural virtual texturing. A 256K × 256K texture in 16 MB.
Mandelbrot fractal rendered using a virtual texture and compressed with Spark.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 26/05/2026
I'm shooting for a new release next Thursday, not of Spark, but of a new Spark integration. I'm happy with how it turned out considering the constraints of the platform. Along with that I'm making some changes to the license terms and will start […] [Original post on mastodon.gamedev.place]
110 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 23/05/2026
Texture2D compressed = Spark.EncodeTexture(source, SparkFormat.RGB); Integrating Spark in your games has never been easier. Coming next week.
010 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 16/05/2026
Some work in progress:
Unity inspector of the runtime texture importer with spark texture compression.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 15/05/2026
Sigh, looks like Unity 6.6 still reproduces a spirv-opt bug that I reported and fixed 2 years ago. Looks like Unity maintains its own version of the DXC compiler and they never picked up the fix.
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 11/05/2026
This was fun to track down: github.com/KhronosGroup/SPIRV-Cross… I found 4 mediump-related bugs in spirv-opt and 1 in spirv-cross. Not sure yet whether this is the right fix.
github.com
GLSL: possible bug in opcode_is_precision_forwarding_instruction · Issue #2630 · KhronosGroup/SPIRV-Cross
Infinite Flight is using the Spark codecs in their most recent release. Their shader pipeline is a bit different to the one I've been testing, and this has surfaced some interesting bugs. This part...
110 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 05/05/2026
Loading images is one of the most fundamental things a browser does. It turns out that doing it efficiently from JavaScript is an open problem. There are multiple APIs available and the right choice depends on the browser you are targeting […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
110 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 03/05/2026
Oh my, looks like I do have a blog post after all. I ended up downloading Firefox source code, reading it to confirm that ImageDecode is indeed supposed to be decoding concurrently, and then finding a lame bug in my benchmark that was making it go through a different code path. 😅 The ImageDecode […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
010 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 02/05/2026
I've been adding more examples to spark.js and now they are deployed automatically to github.io: ludicon.github.io/spark.js Most interesting is the integration with TilesRenderer: ludicon.github.io/spark.js/three-3d… - This […] [Original post on mastodon.gamedev.place]
3d tiles renderer example with spark integration.
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 02/05/2026
Turns out that my initial impression of the ImageDecoder API in Firefox were wrong. Despite the asynchronous nature of the API, decoding still happens in the main thread. Not just that, I also tried using web workers in hopes that the decoding happened in the caller thread, but even with workers […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
130 3
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 01/05/2026
I continue hill-climbing on the BC6 codec. At this point it's the highest perceptual quality BC6 encoder, with the exception of OodleTex, but it runs from 2,000x-30,000x times faster. I think it's quite remarkable I'm getting these results with a […] [Original post on mastodon.gamedev.place]
100 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 24/04/2026
Some reading for the weekend: Implementation details of Apple's lossy texture compression format. www.ludicon.com/castano/blog/2026/0… Enjoy!
ludicon.com
Metal Lossy Compression Format
The Apple Lossy compression format was introduced in the A15 and M2 chipsets (which share the same GPU generation) and it enables sampling and rendering to textures with a 1:2 compression ratio. The format is transparent to the application: the GPU handles compression and decompression automatically, and the block layout is never exposed through the API. A few weeks ago I wrote about it in Hardware Image Compression and gave a brief overview of how it’s enabled at the API level and how the results compare to other hardware compression implementations. In this blog post I’ll take a deeper look at the underlying block format and how the hardware decoder is implemented. Unlike traditional GPU compression formats, the lossy format is not documented. There’s no spec that you can read to understand how the format works. In order to do that I employed the same technique I described in Writing to Compressed Textures in Metal. Metal heaps provide a useful tool to inspect how textures are laid out in memory. Raw compressed bytes can be read by creating a placement MTLHeap and placing both a lossy MTLTexture and an MTLBuffer at offset 0, so they alias the same memory. After blitting data into the lossy texture, the raw compressed bytes can be read from the aliased buffer. The reverse also works: writing raw bytes into the buffer and then reading the aliased texture gives GPU-decompressed pixels. This provides a powerful tool to reverse engineer the format. We can upload simple textures and read back the compressed bits to try to understand how the format works. I started with single channel images (R8 format), first with flat colors, then smooth gradients, simple patterns, random noise, etc. Once I noticed a pattern in the block that would explain the behavior of the output, I would poke at the bits in the block to validate the theory. Following this procedure I quickly discovered some basic facts about the format. The image is broken up into 8×4 blocks and the size of each block is 128 bits. Each block has 1 byte of metadata allocated after the block data. Studying the encoded output for different images it became clear the format supports multiple modes. In some modes there is a base color and per-pixel deltas are applied to it. In others the block is divided into four 4×2 quadrants with a base color for each one. In others the base color is a gradient, similar to ETC2 planar mode, but with deltas applied to it. I discovered all these features by just poking at an 8×4 image using the tool pictured above. When I thought I had most of the details of the format figured out, I tried to decode entire pictures, but to my surprise the results were all wrong. I was clearly missing something. It turned out that blocks aren’t strictly 128 bits. Blocks can use fewer bytes, and they are packed sequentially within a tile. I was assuming blocks were at fixed 16-byte strides to support random access, so I was sampling uninitialized data or bits corresponding to a different block. ## Memory Layout While Apple advertises that lossy formats provide 1:2 compression, the memory use is actually a little higher, because there’s one additional byte of metadata allocated for every 8×4 block (32 pixels). Each of these bytes describes the format of its corresponding block. A compressed texture is laid out in two sections: first the block data, followed by the per-block metadata: Compressed texture memory layout Two stacked sections: a block data section sized at pixel_count times bpp divided by 2, followed by a metadata section sized at max of 128 or num_blocks bytes. Block data section pixel_count · bpp / 2 bytes Metadata section max(128, num_blocks) bytes Within the data section, blocks are grouped into tiles of 2×4 blocks (16×16 pixels, 8 blocks per tile). Each tile is 128 bytes, exactly one cache line, and the 8 blocks inside it are packed sequentially in Y-first Morton order. Here’s the interesting part: the blocks inside a tile are not all the same size. A flat block might use just 1 byte while a complex block takes the full 16. So how do you find block N within a tile without parsing the whole stream? One of the most important properties of a hardware texture format is random access, and walking a variable-length stream to find a block would defeat that. The answer lies in the metadata. The size of each block is fully determined by its metadata byte, so locating a block is a two-step process: load the 16 metadata bytes for the tile, then compute a prefix sum of the block sizes to get the offset. The metadata doubles as an index. Above the tile level, blocks are grouped into macro-tiles of 128×128 pixels (8×8 tiles, or 512 blocks). I covered the motivation for this structure in The True Size of ASTC Textures: aligning texture dimensions to the block grid would normally round up to the next power of two, which wastes a lot of memory. Macro-tiles let the texture round up to the macro-tile boundary instead, which is a much finer granularity. Macro-tiles are arranged in row-major order, and tiles within a macro-tile are in Y-first Morton order. The metadata section doesn’t follow this hierarchy. It uses a single Y-first Morton ordering over the entire block grid, with no macro-tile grouping. It was a bit surprising to discover that the metadata didn’t match the data layout, but since the metadata is much smaller, the waste from power-of-two alignment is less objectionable. Here’s a brief description of all the block types I identified in the R8 Lossy format with their corresponding meta data: Mode| Meta| Description ---|---|--- 0| `0x05, 0x09, 0x0D`| Flat deltas (1/2/3 bpp) 1| `0x25, 0x29, 0x2D`| Masked deltas (1/2/3 bpp) 2| `0x46, 0x4A, 0x4E`| Gradient + deltas (1/2/3 bpp) 3| `0x60, 0x61`| Constant block 4| `0x82..0x8F`| Flat deltas with per-quadrant bit allocation 5| `0xA2..0xAF`| Masked deltas with per-quadrant bit allocation 6| `0xC3..0xCF`| Gradient + deltas with per-quadrant bit allocation 7| `0xF0..0xFF`| Quantized with per-quadrant sub-mode selection As shown in the table, the mode family is determined by the upper 3 bits. The block size is determined as follows: Mode 7 blocks are always 128 bits (16 bytes), for all other modes, the block size is `8 * ((meta & 0x1F) + 1)` bits. Now that we have a high level overview, let’s take a closer view of the block format. ## R8 Lossy Format With the exception of the blocks with meta data `0x60` and `0xF0..0xFF`, all other blocks share a common header: bits[0] = has_global bits[1:3] = bpp - 1 bits[4:11] = 8-bit base color bits[12+] = mode-specific payload When the common header is present, these fields always have the same meaning: a flag indicating whether a global offset is applied to the deltas, the number of bits per delta (`bpp`), and the 8-bit base color that the deltas are applied to. **Mode 3 – Constant** The simplest block is a flat block with a single color. There are two variants of this mode with meta data `0x60` and `0x61`. Blocks with meta `0x61` use the common header layout and provide an 8-bit base color in `bits[4:11]`, but the `has_global` flag and `bpp` fields are unused and must be 0. This results in a block size of 2 bytes. Blocks with meta `0x60` are more compact. The base color is provided in `bits[0:7]` so that the entire block only uses 1 byte. **Mode 0 – Per-Pixel Deltas** This is when things start to get interesting. When you have a nearly flat block, with small variations, you can represent that block exactly with a base color and small deltas. This is what this block does. The base color is provided in the common header and `bits[1:3]` specify the number of bits allocated for each per pixel delta. This leads to 3 variants of this block format depending on the total size, each one with a different metadata: * `0x05`: 1 bpp (48 bits – 6 Bytes). * `0x09`: 2 bpp (80 bits – 10 Bytes). * `0x0D`: 3 bpp (112 bits – 14 Bytes). Only 31 deltas are provided, the first pixel of the block is the anchor and it always takes the value of the base color. When the `has_global` flag is set, there’s an additional entry before the deltas that acts as a global offset subtracted from the per-pixel deltas. To make things more interesting, the deltas are stored in Y-first Morton order. Both deltas and offset are signed values stored in two’s complement, so when only one bit is present the values are either -1 or 0. The final pixel color is given by the following formula: `color = (base + delta - global_offset) & 0xFF` Note that the colors overflow, they wrap around instead of clamping to 0 or 255. This is the case in all modes unless noted otherwise. While this mode sort of makes sense, some of the decisions still baffle me. Why is there a global offset when you can just modify the base color? Why 31 deltas instead of 32? My guess is that the encoder simply reads the first pixel and then compares the remaining pixels against it instead of making an effort to find the optimal base color that could represent a wider range. Would that have been much more expensive? **Mode 1 – Bit-Mask Deltas** This mode is just like mode 0, but the deltas do not affect the corresponding Y-first Morton pixel. Instead, each delta affects a group of up to 8 pixels. The mask values in raster order are as follows: static const uint32_t kDeltaMaskRaster[31] = { 0x00000003, 0x00000100, 0x00000300, 0x00000007, 0x00000008, 0x00000700, 0x00000001, 0x00010000, 0x00030000, 0x01000000, 0x03000000, 0x07070000, 0x08080000, 0x04000000, 0x08000000, 0x000000f0, 0x000000e0, 0x0000f000, 0x0000e000, 0x000000c0, 0x00000080, 0x0000c000, 0x00008000, 0xf0f00000, 0x00e00000, 0x10000000, 0xe0000000, 0x00c00000, 0x00800000, 0xc0000000, 0x80000000, }; This is again quite puzzling. Deltas in one quadrant only affect pixels in the same quadrant. The masks in each quadrant are similar, but not exactly the same, and they lack any kind of symmetry or orthogonality, so it’s unclear to me what’s the best procedure to compute the optimal delta values for a given block. Since individual pixels are affected by multiple deltas, the range of values that can be represented in this mode is higher than the range of a single delta, but it’s not possible to represent all values block within that range. My guess is that the hardware uses a heuristic to choose the deltas and ranks the resulting block against the other options. Like Mode 0, this mode has the following variants: * `0x25`: 1 bpp (48 bits – 6 Bytes). * `0x29`: 2 bpp (80 bits – 10 Bytes). * `0x2D`: 3 bpp (112 bits – 14 Bytes). **Mode 2 – Gradient + Per-Pixel Deltas** One of the most useful blocks in the ETC2 format is the planar mode, but often the resulting gradients are too smooth. Our eyes are very sensitive to small variations in smooth regions, when the grain and or small details are missing, that stands out. Mode 2 solves that by combining smooth gradients with per pixel offsets. The slopes are stored in two 4 bit fields using two’s complement. The way the interpolated color is computed is straightforward: for (int y = 0; y < 4; y++) { for (int x = 0; x < 8; x++) { pixels[y * 8 + x] = base + x_slope * x + y_slope * y; } } The deltas are then applied on top of that. Like in previous modes, there are three variants, for 1, 2 and 3 bpp: * `0x46`: 1 bpp (7 Bytes) * `0x4A`: 2 bpp (11 Bytes) * `0x4E`: 3 bpp (15 Bytes) **Mode 4 – Flat deltas with per-quadrant bit allocation** This mode is similar to mode 0, but divided into 4 sub-blocks (LT, LB, RT, RB), each covering a 4×2 pixel region. The mode has the standard header, with the addition of 4 2-bit codes in `bits[12:19]`. These encode the bpp of each of the sub-blocks as follows: `sub-block bpp = bpp - code`. Sub-blocks with zero or negative bpp are skipped, meaning the all the base color without deltas. The remaining bits provide the per-pixel deltas in y-first Morton order, which is also the sub-block order. Like in previous modes, the delta for the first pixel is skipped (LT has 7 deltas; LB, RT, RB have 8 each). If a global offset is present, it’s applied to the deltas of all sub-blocks. The nibble of the meta-data depends on the size of the block, which is dependent on both, the first 4 header bits and the sub-block codes. These need to match exactly. If the block size is larger than the allocated size, or if it uses fewer bytes than the allocated size, then the block is considered invalid and the output is all 255. **Mode 5 – Masked deltas with per-quadrant bit allocation** This mode is like mode 1, but also using per-quadrant bit allocation. The bit layout is exactly the same as mode 4, but the deltas are applied using the same mask scheme of mode 1. **Mode 6 – Planar + deltas with per-quadrant bit allocation** This mode is just like mode 2, but using per-quadrant bit allocation. The bit layout is exactly the same as mode 4 and 5, but with the 2 slope fields prior to the per pixel deltas. **Putting it all together.** In practice, modes 0, 1, 2, 4, 5 & 6 are all one mode with certain bitfields and features turned on or off. After the standard header you just have the following optional fields: if (mode == 4 || mode == 5 || mode == 6) { qbpp = read_bits(8); } if (has_global) { global = read_bits(bpp); } if (mode == 2 || mode == 6) { x_slope = read_bits(4); y_slope = read_bits(4); } All modes have a base color and deltas applied to it. In modes 2 and 6 the base color is modified by the slopes, while the remaining modes have a flat color. The modes with uniform deltas are just special cases of the modes with per-quadrant deltas. When all deltas have the same bpp, the encoder picks the uniform-delta mode family (0, 1, or 2) which omits the per-quadrant field entirely. **Mode 7 – Quantized with per-quadrant mode selection** In this mode the standard header is not present, instead the layout is determined by the meta data bits. Like the previous modes, this one also divides the block in 4 quadrants. The format of each of these quadrants is determined by the lower 4 bits of the meta data in the usual Y-first Morton order (0->LT, 1->LB, 2->RT, 3->RB). When the corresponding bit is 1, the sub-block is stored using absolute values with 4-bit quantization, otherwise it uses a more complex sub-block delta format. The quantized mode is straightforward: 8 pixels × 4 bits = 32 bits per quadrant, but the 4-bit dequantization is a bit unusual. The most common approach is to replicate the top bits so that the extremes are represented exactly: `(n << 4) | n`, that is, 0 maps to 0 and `0xF` (15) maps to `0xFF` (255). However, the lossy format achieves that by handling the extremes as special cases and then using a nearly uniform quantization interval. inline uint8_t lossy_dequantize(uint8_t n) { if (n == 0) return 0; if (n == 15) return 255; return (n << 4) | (n & 1 ? 7 : 8); } This results in the following possible values: n: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 q: 0 23 40 55 72 87 104 119 136 151 168 183 200 215 232 255 Why this choice? I have no idea. I would understand the use of uniform quantization intervals, since that makes quantization easier, and snapping the endpoints to the extremes makes sense, but the alternating 17-15 interval is a curious choice. The sub-block delta format encodes 8 pixels (4×2 region) using a base value plus per-pixel deltas encoded in 32 bits. The first 3 bits encode a header that selects the step size and the way the deltas are applied. The following 5 bits provide the base color, and the remaining data is mode-dependent. bits[0:2] 3-bit header bits[3:7] 5-bit base color bits[8:31] 24-bit per-pixel data This provides 8 different modes, but with the exception of mode 0, all have the same structure: A base value, an anchor pixel selector, and 7 per-pixel deltas, but pack them differently into the remaining 24 bits: | Fine Step (`bit2==0`)| Large Steps (`bit2==1`) ---|---|--- Bits per delta| 2 bits| 3 bits Global offset| 3 bits| — Base value| `base5`| `base5 * 8 + round` Step sizes| 1, 2, 4| 4, 8, 16, 32 The fine step mode trades delta precision (2 bpp) for a 3-bit global offset (value × 32), giving coarse block-wide shifts. The large step mode uses those same bits for an extra delta bit per pixel (3 bpp) that combined with the larger step sizes allows addressing a larger range of values. The anchor pixel selector uses 3 bits to specify the pixel that does not have deltas applied to it. The dequantization procedure is also interesting. The anchor pixel is simply: pixel[anchor] = base + global + round where the round is mode-dependent and annoyingly not `step/2`. The non-anchor pixels are: pixel[i] = base + global + delta * step + step/2; where step is mode dependent and delta is the per-pixel delta. As in the absolute quantization mode, special attention is paid to reproduce the extremes exactly by snapping them to the endpoints. When the delta is 0 and the resulting pixel value is within `step/2` of 0, it snaps to 0. When delta is 255 and the pixel value is within `step/2` of 255, it snaps to 255. Additionally, unlike all the other modes, when values overflow the 8-bit range, the values do not wrap, but are clamped to 0 and 255. Finally, when the 3-bit sub-block header is 0 the data encodes a constant or near-constant block using 1 bit per pixel offsets. This description is not a full specification. I’ve glossed over some of the details in order to make the text more tolerable. I’d be happy to provide a reference decoder if anyone is interested. ## Conclusions It’s clear that some of the features of the format are designed not just to save memory, but to save bandwidth. When a tile has a flat color, the entire tile is represented in just 8 bytes, while more complex tiles can take up to 128 bytes. I suspect the lossless format has a similar structure, but with a larger tile size to allow for blocks stored without compression or quantization while still providing bandwidth savings in common scenarios. My guess is that modes 0-6 behave the same way, with a limit of 256 bits instead of 128, and that mode 7 encodes a fully decompressed block instead of using quantization. While I think I have a good understanding of the underlying format, I still have some open questions. Some of the choices in the format are puzzling and perhaps that’s a sign that I still don’t have a complete understanding. How would one write a fast, efficient encoder for this format? Initially I thought the encoder would be implemented in software, in a way not too different to what I’ve done for _Spark_. Apple even has support for Tile shaders, which seem particularly well suited for this task. However, my attempts to use Tile shaders for this purpose haven’t produced satisfactory outcomes. My conclusion is that the encoder is fully implemented in hardware. An encoder for this format should not require the use of floating point math, so the implementation could be fairly compact. A hardware encoder could target multiple modes in parallel and select the best match. Beyond R8, I’ve also fully decoded the RG8 format. It follows the same principles and has many similarities, but it’s significantly more complex. Bit allocation for each of the two components is variable, and the sub-block delta format is convoluted enough that I had to rely on a 256-element table to map the header bits to all the possible bit allocations. I could not find a formula or pattern that worked for all cases. I find it unlikely that this is how the format was actually designed, so I hope I’m just missing something. The RGBA8 format is likely to be even more intricate, and my effort to fully decode it is on pause. Partly because I’m losing steam, and partly because this work doesn’t have any immediate practical application. It’s been a fun and intriguing exercise, and I’ve learned a few things along the way, but whether any of this knowledge turns out to be useful elsewhere remains to be seen.
001 1
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 24/04/2026
Looks like I'm locked out of both Apple and Google's developer programs. I let my memberships lapse while dealing with more pressing things, and now I'm trying to restore them, but my Apple account is locked and Google is rejecting my re-enrollment with no explanation. I opened a support […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
000 0
Ignacio Castaño🍉 @castano.mastodon.gamedev.place.ap.brid.gy · 23/04/2026
I just learned about the ImageDecoder API: developer.mozilla.org/en-US/docs/We… Not supported in WebKit, and on Chrome doesn't support AVIF files, but it works great in Firefox, and most importantly, it's fully asynchronous, images are loaded concurrently in worker […]
mastodon.gamedev.place
Original post on mastodon.gamedev.place
000 0