{"id":1814,"date":"2026-05-04T19:27:16","date_gmt":"2026-05-05T03:27:16","guid":{"rendered":"https:\/\/www.ludicon.com\/castano\/blog\/?p=1814"},"modified":"2026-05-04T21:25:30","modified_gmt":"2026-05-05T05:25:30","slug":"image-loading-on-the-web","status":"publish","type":"post","link":"https:\/\/www.ludicon.com\/castano\/blog\/2026\/05\/image-loading-on-the-web\/","title":{"rendered":"Image Loading on the Web"},"content":{"rendered":"\n<p>You would think that loading images in the browser is a solved problem. After all, websites are basically composed of text and images.<\/p>\n\n\n\n<p>Turns out that loading images from JavaScript to use in WebGL\/WebGPU applications is not nearly as simple or well supported as it should be. There are several APIs for it, and the right choice depends on which browser you are targeting. They each have different performance characteristics, and some have unexpected behaviors or outright bugs.<\/p>\n\n\n\n<p>This is surprising considering that loading images is one of the most fundamental things a browser does.<\/p>\n\n\n\n<!--more-->\n\n\n\n<p>Both WebGL and WebGPU make it fairly easy to create a texture from various image representations.<\/p>\n\n\n\n<p>In WebGL the <code>gl.texImage2D<\/code> function accepts two types of sources, a <code>TypedArray<\/code> or a DOM pixel source. The latter includes any of these objects:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/ImageBitmap\" target=\"_blank\" rel=\"noreferrer noopener\"><code>ImageBitmap<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/ImageData\" target=\"_blank\" rel=\"noreferrer noopener\"><code>ImageData<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLImageElement\" target=\"_blank\" rel=\"noreferrer noopener\"><code>HTMLImageElement<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLCanvasElement\" target=\"_blank\" rel=\"noreferrer noopener\"><code>HTMLCanvasElement<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/HTMLVideoElement\" target=\"_blank\" rel=\"noreferrer noopener\"><code>HTMLVideoElement<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/OffscreenCanvas\" target=\"_blank\" rel=\"noreferrer noopener\"><code>OffscreenCanvas<\/code><\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/VideoFrame\" target=\"_blank\" rel=\"noreferrer noopener\"><code>VideoFrame<\/code><\/a><\/li>\n<\/ul>\n\n\n\n<p>Similarly, in WebGPU we can populate <code>GPUTexture<\/code> objects using <code>queue.copyExternalImageToTexture<\/code> which accepts the same DOM pixel sources.<\/p>\n\n\n\n<p>With all these options, it&#8217;s up to the developer to choose what pathway to use. Sometimes the image data is already in a particular form and the choice is made for you. But most of the time you&#8217;re fetching an image from a URL, and the question becomes: which API gets the pixels onto the GPU fastest?<\/p>\n\n\n\n<p>As we will see, WebGPU is still a young API with immature implementations, and depending on the pathway you pick, you can also run into color space bugs or formats that simply don&#8217;t work.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><code>createImageBitmap<\/code><\/h2>\n\n\n\n<p>The <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Window\/createImageBitmap\" target=\"_blank\" rel=\"noreferrer noopener\"><code>createImageBitmap<\/code><\/a> method returns a promise that resolves to an <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/ImageBitmap\" target=\"_blank\" rel=\"noreferrer noopener\"><code>ImageBitmap<\/code><\/a> and, in theory, it provides an asynchronous and resource-efficient pathway to prepare textures for rendering in WebGPU and WebGL that is widely supported across browsers.<\/p>\n\n\n\n<p>Like the WebGL and WebGPU APIs, it accepts a variety of image sources, but the most useful input is a <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Blob\" target=\"_blank\" rel=\"noreferrer noopener\"><code>Blob<\/code><\/a>. This gives us a clean separation between fetching the image and decoding it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export async function loadImageBitmap(url) {\n  const res = await fetch(url, { mode: \"cors\" })\n  if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`)\n  const blob = await res.blob()\n\n  return createImageBitmap(blob, {\n    imageOrientation: \"none\",\n    colorSpaceConversion: \"none\",\n    premultiplyAlpha: \"none\"\n  })\n}<\/code><\/pre>\n\n\n\n<p>Additionally it provides fine grained control over the image orientation, color space conversion and alpha format, which are features missing from the other pathways.<\/p>\n\n\n\n<p>Unfortunately, there are some issues:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>SVG support is inconsistent.<\/strong> While the API promises support for SVG, the quality of the resulting images depends on the browser. Firefox produces the expected output, but in Safari and Chrome the resulting images are not antialiased. Additionally, if you provide an SVG file inside a blob (instead of an <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/SVGImageElement\" target=\"_blank\" rel=\"noreferrer noopener\"><code>SVGImageElement<\/code><\/a>) Chrome does not produce valid output.<\/li>\n\n\n\n<li><strong>Color space conversion bugs.<\/strong> Safari has known issues when passing <code>ImageBitmap<\/code> to <code>copyExternalImageToTexture<\/code> (<a href=\"https:\/\/bugs.webkit.org\/show_bug.cgi?id=296208\" target=\"_blank\" rel=\"noreferrer noopener\">bug 296208<\/a>, <a href=\"https:\/\/bugs.webkit.org\/show_bug.cgi?id=295729\" target=\"_blank\" rel=\"noreferrer noopener\">and 295729<\/a>). These issues are fixed in Tahoe\/iOS 26, but given the low adoption rate of this release they are worth keeping in mind.<\/li>\n\n\n\n<li><strong>Asynchronous does not mean concurrent.<\/strong> The asynchronous nature of the API allows browser implementations to offload the decoding to a separate thread, but in practice the only browser that seems to do this is Chrome. In Firefox decoding is serial (<a href=\"https:\/\/bugzilla.mozilla.org\/show_bug.cgi?id=1778394\" target=\"_blank\" rel=\"noreferrer noopener\">bug 1778394<\/a>) and Safari is slow, although I haven&#8217;t pinned down why.<\/li>\n<\/ul>\n\n\n\n<p>Even though on paper <code>createImageBitmap<\/code> is the right API, in practice we have to look at the other alternatives to find more efficient solutions or work around deficiencies in the implementations.<\/p>\n\n\n\n<p><strong>Summary:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Chrome: Poor SVG support.<\/li>\n\n\n\n<li>Safari: Poor SVG support. Color space conversion bugs.<\/li>\n\n\n\n<li>Firefox: Correct, but synchronous. Decoding in the main thread.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><code>HTMLImageElement<\/code><\/h2>\n\n\n\n<p>The <code>HTMLImageElement<\/code> interface represents an HTML <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/HTML\/Reference\/Elements\/img\" target=\"_blank\" rel=\"noreferrer noopener\"><code>&lt;img><\/code><\/a> element, which we can hand over to the WebGL and WebGPU APIs directly. To create an image element asynchronously we can wrap it in a promise that is fulfilled when the image finishes loading:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>export function loadImageElement(url) {\n  return new Promise((resolve, reject) =&gt; {\n    const img = new Image()\n    img.crossOrigin = \"anonymous\"\n    img.decoding = \"async\" \/\/ hint to decode off the main thread when possible\n    img.onload = () =&gt; resolve(img) \/\/ returns HTMLImageElement\n    img.onerror = reject\n    img.src = url\n  })\n}<\/code><\/pre>\n\n\n\n<p>The <code>decoding<\/code> attribute is an optional string representing a hint given to the browser on how to decode the image. <code>async<\/code> means to decode it asynchronously. In practice the hint doesn&#8217;t seem to make much of a difference. Firefox still decodes synchronously in the main thread, and Safari appears only slightly faster.<\/p>\n\n\n\n<p>One of the drawbacks of this API is that there&#8217;s no way to specify the intended color space conversion or alpha format. This is a problem when loading images that are not intended for display, for example, normal maps or other texture data stored in linear space. In those cases Chrome seems to apply an sRGB transform, and produces washed-out colors, while Safari and Firefox leave the data as is. It&#8217;s unclear which behavior is correct, but for texture loading, Chrome&#8217;s transform makes this method unusable.<\/p>\n\n\n\n<p>This code path also supports loading SVG files, and in most cases the quality of the results is much better. However, prior to Safari 26, loading of image elements referencing SVG files was broken. A workaround is to render the SVG to a canvas which both WebGL and WebGPU accept as an input:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>async function convertImageElementToCanvas(img) {\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = img.naturalWidth || img.width\n  canvas.height = img.naturalHeight || img.height\n  const ctx = canvas.getContext(\"2d\")\n  ctx.drawImage(img, 0, 0)\n  return canvas\n}<\/code><\/pre>\n\n\n\n<p>With this fix, loading of SVG files through the <code>HTMLImageElement<\/code> code path produces correct results in all cases.<\/p>\n\n\n\n<p><strong>Summary:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Chrome: Inconsistent color space conversion.<\/li>\n\n\n\n<li>Safari: Fastest option. Issue with SVG.<\/li>\n\n\n\n<li>Firefox: Still synchronous.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\"><code>ImageDecoder<\/code><\/h2>\n\n\n\n<p>A third alternative is to use the new WebCodecs API. Adoption of this API has been limited, because it&#8217;s only available in Chrome and Firefox. I was excited to try it out, because I had heard rumors about it being hardware accelerated. Probably not true, but the rumor itself suggested it might at least be faster than the alternatives.<\/p>\n\n\n\n<p>The code to use the <code>ImageDecoder<\/code> API is fairly simple:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>const decoder = new ImageDecoder({\n  data: blob.stream(),\n  type: blob.type,\n  colorSpaceConversion: \"none\",\n  preferAnimation: false\n})\n\ntry {\n  \/\/ Returns a VideoFrame; caller is responsible for calling .close() on it.\n  const { image } = await decoder.decode()\n  return image\n} finally {\n  decoder.close()\n}<\/code><\/pre>\n\n\n\n<p><code>decoder.decode()<\/code> returns a promise to an object with an image attribute containing a <code>VideoFrame<\/code> and we can create a texture from this <code>VideoFrame<\/code> directly. Like <code>createImageBitmap<\/code>, this code path supports specifying the desired color space conversion, but unlike it, there&#8217;s no way to specify the expected alpha format.<\/p>\n\n\n\n<p>There&#8217;s another caveat, in Firefox <code>copyExternalImageToTexture<\/code> does not support <code>VideoFrame<\/code>. This is a known issue that has already been reported at: <a href=\"https:\/\/bugzilla.mozilla.org\/show_bug.cgi?id=1975308\" target=\"_blank\" rel=\"noreferrer noopener\">bug 1975308<\/a>, but the workaround is fairly simple. We just wrap the <code>VideoFrame<\/code> object in an <code>ImageBitmap<\/code>. This is something that <code>spark.encodeTexture<\/code> already handles in order to support arbitrary video sources:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>if (getFirefoxVersion() &amp;&amp; isVideoFrame) {\n  const bitmap = await createImageBitmap(source)\n  try {\n    return await this.encodeTexture(bitmap, options)\n  } finally {\n    bitmap.close()\n  }\n}<\/code><\/pre>\n\n\n\n<p>To benchmark this I used the <a href=\"https:\/\/github.com\/ludicon\/sponza-gltf\" target=\"_blank\" rel=\"noreferrer noopener\">glTF Sponza scene<\/a> I tested on a <a href=\"https:\/\/www.ludicon.com\/castano\/blog\/2026\/02\/an-updated-sponza-gltf\/\" target=\"_blank\" rel=\"noreferrer noopener\">previous blog post<\/a>. <\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"700\" height=\"368\" src=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-700x368.png\" alt=\"\" class=\"wp-image-1819\" srcset=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-700x368.png 700w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-267x140.png 267w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-768x404.png 768w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1536x808.png 1536w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2048x1077.png 2048w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-800x421.png 800w\" sizes=\"auto, (max-width: 700px) 100vw, 700px\" \/><\/figure>\n<\/div>\n\n\n<p>Here are the results:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Browser<\/th><th><code>createImageBitmap<\/code><\/th><th><code>ImageDecoder.decode<\/code><\/th><\/tr><\/thead><tbody><tr><td>Chrome<\/td><td>177 ms<\/td><td>163 ms<\/td><\/tr><tr><td>Firefox<\/td><td>460 ms<\/td><td>196 ms<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>The improvements in Chrome are modest, but on Firefox the difference is dramatic, suggesting the texture decoding finally happens off the main thread. Here&#8217;s a flame graph of the version of the code using <code>createImageBitmap<\/code>, note how most of the time is spent in image decoding (in green):<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"700\" height=\"178\" src=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-700x178.png\" alt=\"\" class=\"wp-image-1820\" srcset=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-700x178.png 700w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-267x68.png 267w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-768x195.png 768w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-1536x391.png 1536w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-2048x521.png 2048w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-1-800x204.png 800w\" sizes=\"auto, (max-width: 700px) 100vw, 700px\" \/><\/figure>\n<\/div>\n\n\n<p>With <code>ImageDecoder<\/code>, the decoding time is gone entirely, and most of the time is spent on WebGPU&#8217;s <code>copyExternalImageToTexture<\/code> (in blue):<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"700\" height=\"270\" src=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2-700x270.png\" alt=\"\" class=\"wp-image-1821\" srcset=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2-700x270.png 700w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2-267x103.png 267w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2-768x297.png 768w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2-1536x593.png 1536w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2-800x309.png 800w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-2.png 1600w\" sizes=\"auto, (max-width: 700px) 100vw, 700px\" \/><\/figure>\n<\/div>\n\n\n<p>However, while the overall time is reduced, we are now spending a significant amount of time copying data, and in particular, doing pixel format conversions. Turns out that the <code>VideoFrame<\/code> internal format is BGRA8 and our WebGPU texture is RGBA8.<\/p>\n\n\n\n<p>I spent some time looking at that code to understand what I could do about that.<\/p>\n\n\n\n<p>The <code>ConvertImage<\/code> function has a fast path for no-pixel format conversion that just performs memory copies, and a slow path that uses a generic <code>WebGLImageConverter<\/code>. Firefox actually has some optimized pixel swizzling functions that have SSE2 and NEON implementations (<code>gfx::SwizzleData<\/code> and <code>gfx::SwizzleYFlipData<\/code>). I tried hooking them up and produced a noticeable improvement, but a better approach is to avoid the pixel conversion entirely.<\/p>\n\n\n\n<p>Changing the pixel format used by Firefox internally would be tricky. There&#8217;s a lot of code that expects that particular format. Instead it&#8217;s much easier to change the format of our destination texture. We only use this intermediate texture as a staging texture prior to block compression and it doesn&#8217;t matter whether it uses RGBA8 or BGRA8. Allocating a BGRA8 texture puts us in the optimized memory copy path.<\/p>\n\n\n\n<p>The resulting timings are even better (note, I did not measure this change in Chrome):<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Browser<\/th><th><code>createImageBitmap<\/code><\/th><th><code>ImageDecoder<\/code> -&gt; RGBA<\/th><th><code>ImageDecoder<\/code> -&gt; BGRA<\/th><\/tr><\/thead><tbody><tr><td>Firefox<\/td><td>460 ms<\/td><td>142 ms<\/td><td>116 ms<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>This is a nice improvement, but I&#8217;m still not satisfied with this outcome. The remaining copy is necessary in Firefox, because WebGPU runs in a different process and in order to transfer the texture we have to place it in shared memory that is specifically allocated for this purpose. In an ideal world we would have the decoder thread output its result directly into shared memory, so that the main thread only has to hand it over to the GPU process without touching it.<\/p>\n\n\n\n<p>Another problem that still remains is that textures with alpha still go through a pixel format conversion. Turns out that the alpha channel in <code>VideoFrame<\/code> objects is always premultiplied. On the other hand <code>copyExternalImageToTexture<\/code> expects unpremultiplied alpha by default, so the pixel format converter has to divide the RGB by the alpha.<\/p>\n\n\n\n<p>Unfortunately, there appears to be no way to specify that the alpha in the <code>VideoFrame<\/code> is not to be premultiplied. We can eliminate the un-premultiplication, by requesting premultiplied data in <code>copyExternalImageToTexture<\/code>, but that&#8217;s not what most applications want; often the alpha in game textures is used to encode data that&#8217;s not opacity.<\/p>\n\n\n\n<p>This is worrying not just because of the minor overhead, but because of potential quality issues. Premultiplication followed by unpremultiplication is a lossy transformation, it completely destroys RGB data in areas that are fully transparent, and when the alpha is close to zero the reconstruction is inaccurate and often leads to fireflies. You can see an example on the texture below:<\/p>\n\n\n<div class=\"wp-block-image\">\n<figure class=\"aligncenter size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"700\" height=\"343\" src=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-3-700x343.png\" alt=\"\" class=\"wp-image-1822\" srcset=\"https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-3-700x343.png 700w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-3-267x131.png 267w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-3-768x376.png 768w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-3-800x392.png 800w, https:\/\/www.ludicon.com\/castano\/blog\/wp-content\/uploads\/2026\/05\/image-3.png 1110w\" sizes=\"auto, (max-width: 700px) 100vw, 700px\" \/><\/figure>\n<\/div>\n\n\n<p>The RGBA codecs in spark.js assume that alpha is used for opacity and handle input like this gracefully, but this is still an issue if you intend to use the alpha channel for other purposes and don&#8217;t want it to interfere with the color.<\/p>\n\n\n\n<p>Finally, another issue is that support for the BGRA8 format is intentionally limited in WebGPU. It does not support <code>STORAGE_BINDING<\/code> unless the <code>\"bgra8unorm-storage\"<\/code> feature is supported and enabled, and even then, this only enables write-only access. That puts some serious restrictions in how we can use these textures.<\/p>\n\n\n\n<p>For example, we cannot use our compute mipmap generation without adding an extra blit. Currently Firefox does not support the compute-based mipmap generation because support for views with different pixel formats is also broken, but once that&#8217;s fixed this will add some overhead and additional complexity to that code path.<\/p>\n\n\n\n<p><strong>Summary:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Chrome: Minor improvement. Possible alpha quality degradation.<\/li>\n\n\n\n<li>Safari: Not available.<\/li>\n\n\n\n<li>Firefox: Asynchronous and concurrent. Still some copy overhead. Alpha quality degradation.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusions<\/h2>\n\n\n\n<p>I spent quite a bit of time looking at performance in Firefox because it&#8217;s my browser of choice and also because I feel it has much fewer engineering resources than Chrome or Safari. That said, some of the issues I identified probably apply to the other browsers; I suspect the BGRA staging texture trick is worth trying regardless of your target.<\/p>\n\n\n\n<p><strong>Summary:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>For raster images:\n<ul class=\"wp-block-list\">\n<li>Chrome: <code>createImageBitmap<\/code> works as advertised, concurrent decoding, correct color handling.<\/li>\n\n\n\n<li>Safari: use <code>HTMLImageElement<\/code>. It&#8217;s faster than <code>createImageBitmap<\/code> and avoids bugs in pre-Tahoe versions.<\/li>\n\n\n\n<li>Firefox: use <code>ImageDecoder<\/code>. It&#8217;s the only API on Firefox that decodes off the main thread.<\/li>\n<\/ul>\n<\/li>\n\n\n\n<li>For vector images: use <code>HTMLImageElement<\/code> or render to canvas in Safari prior to version 26.<\/li>\n<\/ul>\n\n\n\n<p>So much for portability of web APIs! The nice thing is that at least you can use <a href=\"https:\/\/github.com\/ludicon\/spark.js\" target=\"_blank\" rel=\"noreferrer noopener\">spark.js<\/a> to abstract all that complexity.<\/p>\n\n\n\n<p>I intend to keep updating spark.js to enable the fastest texture upload path possible and to work around all the wrinkles in each of the browsers. It would be great if some of the issues I&#8217;ve identified were fixed over time, but in the meantime spark.js provides a practical solution that&#8217;s available today.<\/p>\n\n\n\n<p>On that note, here&#8217;s a wish list of things that would make this whole landscape simpler, both for spark.js and for anyone else trying to load images efficiently on the web:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A simple way to upload image data without having to perform memory copies, pixel format conversions or alpha unpremultiplication in the main thread. <code>createImageBitmap<\/code> is probably the right API for this, it just needs consistent implementation across all browsers.<\/li>\n\n\n\n<li>Lower level access to raw pixel data in YUV format, as produced by the decoder. Since our primary goal is to upload the pixel data to the GPU, performing the color conversion there would make more sense than doing it in the CPU.<\/li>\n\n\n\n<li>Support for HDR images. AVIF, JPEG and HEIF all have HDR variants, and some of them are supported by browsers in <code>&lt;img&gt;<\/code> elements, but the output is always tone mapped. 3D applications need access to untonemapped data in a <code>Uint16Array<\/code> \/ float buffer. Or direct access to the raw color and gain maps.<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>You would think that loading images in the browser is a solved problem. After all, websites are basically composed of text and images. Turns out that loading images from JavaScript to use in WebGL\/WebGPU applications is not nearly as simple or well supported as it should be. There are several APIs for it, and the&#8230;<\/p>\n","protected":false},"author":1,"featured_media":1825,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[9,22],"tags":[],"class_list":["post-1814","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-coding","category-spark"],"_links":{"self":[{"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/posts\/1814","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/comments?post=1814"}],"version-history":[{"count":10,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/posts\/1814\/revisions"}],"predecessor-version":[{"id":1829,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/posts\/1814\/revisions\/1829"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/media\/1825"}],"wp:attachment":[{"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/media?parent=1814"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/categories?post=1814"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.ludicon.com\/castano\/blog\/wp-json\/wp\/v2\/tags?post=1814"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}