Skip to content

Add spark.js support to three.js 3DTilesRenderer#1497

Draft
castano wants to merge 6 commits into
NASA-AMMOS:masterfrom
Ludicon:spark
Draft

Add spark.js support to three.js 3DTilesRenderer#1497
castano wants to merge 6 commits into
NASA-AMMOS:masterfrom
Ludicon:spark

Conversation

@castano
Copy link
Copy Markdown
Contributor

@castano castano commented Mar 5, 2026

This is just a proof of concept to demonstrate spark.js integration in the 3DTilesRenderer. For more details see this blog post:

https://www.ludicon.com/castano/blog/2026/03/announcing-spark-js-0-1/

@gkjohnson
Copy link
Copy Markdown
Contributor

Hello! Can you provide some more context for this PR?

@castano
Copy link
Copy Markdown
Contributor Author

castano commented Mar 6, 2026

This is just a proof of concept to demonstrate spark.js integration in the 3DTilesRenderer. You can find more details in this blog post:

https://www.ludicon.com/castano/blog/2026/03/announcing-spark-js-0-1/

If there's interest in considering this for integration I'd be happy to to add more details and submit it for review.

Copy link
Copy Markdown
Contributor

@gkjohnson gkjohnson left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can find more details in this blog post

Thanks, this is helpful. Just to make sure I understand: it sounds like spark allows for textures to be transcoded at run time to memory-efficient, GPU-compatible formats, allowing for formats like webp (small on disk footprint) to be used for download while gpu-optimized formats are used at run time (small in-memory footprint), is that right?

I see there are screenshots showing increased geometric detail with spark, implying reduced memory usage so more tiles can fit in the cache, but it would be helpful to see a before / after table detailing comparisons of on-disk size, in-memory size, and additional parse time required due to transcoding from the library so the tradeoffs are clear.

Comment on lines +12 to +17
// IC: The code below assumes tex was created from an ImageBitmap
if ( tex instanceof ExternalTexture && tex.userData?.byteLength ) {

return tex.userData.byteLength;

}
Copy link
Copy Markdown
Contributor

@gkjohnson gkjohnson Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if an "ExternalTexture" is provided without userData.byteLength? It looks like "image" will be "null" on the Texture, then and the follow code will crash, right? Assuming we can't get the actual texture size from the ExternalTexture handle we should figure out a reasonable default behavior when byteLength isn't provided.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code will crash with ExternalTextures generated by other tools. It also won't compute the size of CompressedTextures correctly, as the generateMipmaps parameter is forced to false, but textures may still have mipmaps. I'd be happy to robustify this code. I was aiming for minimal changes here.

Copy link
Copy Markdown
Contributor

@gkjohnson gkjohnson Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making it more robust would be great. I expect it's not possible to determine the actual in-memory size of one of these textures from just the WebGLTexture (or WebGPU) handle itself, right? At least not without the gl contenxt. In lieu of any other information being available for calculating the size I was thinking we could check for a custom field (as you are here) and otherwise fallback to a fixed memory size of a 64x64 texture or something just so the value isn't "0" in the cache.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll propose that change in a separate PR. Here I just wanted to keep it simple to demonstrate that the changes required for integration are minimal.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be great. A PR to improve handling of just ExternalTextures is something we could merge immediately. A couple other things that come to mind that need to be handled:

  • How is disposal handled for these external textures? Does calling ExternalTexture.dispose actually dispose of the WebGLTexture / WebGPUTexture handle? Or does disposal need to happen separately, as with an imageBitmap?
  • The UnloadTilesPlugin is designed to delete any GPU memory for tiles that aren't actively visible or being rendered. When the tiles are made visible again the textures and geometry are re-initialized on the GPU. I assume this model wouldn't work for the ExternalTexture since re-initializing it isn't as simple as just reuploading the existing data? The easy thing to do in that case is to just ignore and not dispose ExternalTextures encountered in the plugin.

Copy link
Copy Markdown

@donmccurdy donmccurdy Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... I think my primary concern is that as it is we can't notify a user that their texture isn't being cleaned up if they're using a non-Spark ExternalTexture instance...

Hm, my original intention for THREE.ExternalTexture had been that if the user (or a library the user delegates to) creates a GPU resource, they must also manage the GPU resource throughout its lifecycle. From that perspective:

  1. It feels unexpected that the user must store the WebGL context on the texture
  2. It should be up to the user (or delegate library) to decide whether the GPU resource has a 1:1 or 1:many relationship with ExternalTexture container(s), and to manage GPU resources accordingly

If the user is relying on Spark to create these ExternalTextures, it becomes Spark's responsibility to either manage the resource lifecycle, or communicate that responsibility to the user. If the user has created an ExternalTexture manually or with some other dependency, it would be the user and/or dependency's responsibility to do the same.

The proposal Ludicon/spark.js#31 is consistent, I think, in defining a clear contract for the lifecycle of the ExternalTexture and its GPU resource. Arguably, GLTFLoader is not doing so good of a job there, by constructing ImageBitmap textures in unspecified conditions, and leaving it to the user to dispose those resources manually... which I'd guess is often overlooked. I don't have an easy fix for that, but it's not a pattern I'd want to promote.


I think it's not practical for an ExternalTexture to store enough metadata for its GPU texture to be recreated after disposal, would you agree? The original image should no longer exist in memory. Assuming so... maybe a spark.destroyTexture(gpuTexture) utility would be helpful rather than passing around the GL context? The typical pattern is that three.js textures can be reused after disposal, so 3DTilesRendererJS could dispose the texture eagerly, and the GPU resource only after the tile is finally unloaded.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@donmccurdy I guess fundamentally I'm confused as to why one would advocate for his behavior in the spark library but not with ImageBitmap in GLTFLoader - it's just as possible for GLTFLoader to register the same kind of disposal event but I think it's known this causes massive usability issues, which is why it is the way it is.

If the user is relying on Spark to create these ExternalTextures, it becomes Spark's responsibility to either manage the resource lifecycle, or communicate that responsibility to the user.

To be clear I completely agree that symmetry in the life cycle of the texture is both a nicer and safer pattern. The problem is the practical issues it causes. The suggested pattern betrays the expectations by every other Texture instance in the project. Off the top of my head my own real-world use cases this would have broken if it behaved this way:

  • Disposing of a texture to temporarily replace it with another, then swapping the original texture back in later.
  • Disposing of resources to store them in a CPU-side cache to avoid having to reload them again when needed.
  • Cloning a texture to adjust offsets, then disposing the original.
  • Disposing of a cloned instance will not actually dispose of the external texture handle

All of these very reasonable use cases would wind up breaking if textures were managed in the way that is being suggested.

It should be up to the user (or delegate library) to decide whether the GPU resource has a 1:1 or 1:many relationship with ExternalTexture container(s), and to manage GPU resources accordingly

This isn't how three.js' API presents itself though. Perhaps it's just becoming clear that three.js' API surface was not designed for these kinds of use cases if even just because the original source image textures used did not have these issues. Why should "dispose" behave inconsistently for image bitmap or external texture? Perhaps it's just the way I'm thinking about but as I mentioned before, "dispose" has never meant "dispose content forever" in three.js. Perhaps there's are argument to be made for adding a "disposeForever" function to three.js textures to accommodate these types of use cases.

I think it's not practical for an ExternalTexture to store enough metadata for its GPU texture to be recreated after disposal, would you agree

Of course agree with this. But I also think that I should also be able to call "dispose" and expect consistent behavior. These classes are supposed to be reliable abstractions with consistent, usable behavior so I'm not in agreement that the "dispose" function should suddenly be overloaded to completely discard resources and make a texture instance unusable, which again doesn't happen anywhere else.

Assuming so... maybe a spark.destroyTexture(gpuTexture) utility would be helpful rather than passing around the GL context?

My preference is to not add library-specific code throughout the project to accommodate every special case someone may come to the project with.

--

Either way if this is the preference for how to manage the disposal of these textures that's fine. I only intended to point out what will be ergonomic issues down the line (and within this project).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The more I learn about how ExternalTextures work, the more confused I am. The behavior of the WebGL and WebGPU backends are entirely different.

In WebGPU, it appears that the behavior depends on whether the texture has been used for rendering or not. During rendering, three calls Textures.updateTexture and this registers the textureData in the Textures DataMap and enables the shared ownership behavior that you have come to expect.

This is quite confusing. If you call dispose() prior to rendering, nothing happens, if you call it after, the texture is destroyed. If you have multiple textures referencing the same GPUTexture, but some have been used for rendering and others haven't, then the behavior is completely broken.

The WebGL backend doesn't appear to behave like that, and it never takes ownership of the textures objects. This is at least more consistent.

I'm working on a solution that involves the creation of a map to manage the lifetime of spark external textures. Instead of using THREE.ExternalTexture to create a texture, you use a derived class that overrides the copy constructor and the dispose method and tracks the gl object associated with spark and the number of textures that reference it, so that it can dispose the texture object when no texture references it anymore. I'll post an update once I have something solid.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, here's what I have:

Ludicon/spark.js@f2915cd

Usage is as follows:

        const gl = this.spark.gl
        const onRelease = isWebGL ? tex => gl.deleteTexture(tex) : tex => tex.destroy()
        const texture = new SparkThreeExternalTexture(gpuTexture, onRelease)

in the case of the 3DTilesRenderer no changes are necessary. The GLTF loader plugin takes care of creating the external texturs using the SparkThreeExternalTexture class.

It works as expected in my examples, but it needs more testing. Let me know what you think of this approach.

Copy link
Copy Markdown
Contributor

@gkjohnson gkjohnson May 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During rendering, three calls Textures.updateTexture and this registers the textureData in the Textures DataMap and enables the shared ownership behavior that you have come to expect.

This difference in behavior between WebGLRenderer and WebGPURenderer may not be intended or desired. WebGPURenderer is still very much in development and there are still quite a few oversights like this throughout.

OK, here's what I have:

Ludicon/spark.js@f2915cd

This doesn't completely address everything but I think this is fundamentally something that three.js needs to promote a more consistent pattern for if it's going to become more commonly used. I think we can wait for more problems to arise before trying to address this further.

Comment thread src/three/renderer/utils/MemoryUtils.js
Comment thread src/three/plugins/GLTFExtensionsPlugin.js Outdated
@castano
Copy link
Copy Markdown
Contributor Author

castano commented Mar 8, 2026

Thanks, this is helpful. Just to make sure I understand: it sounds like spark allows for textures to be transcoded at run time to memory-efficient, GPU-compatible formats at run time, allowing for formats like webp (small on disk footprint) to be used for download while gpu-optimized formats are used at run time (small in-memory footprint), is that right?

That's exactly right.

I see there are screenshots showing increased geometric detail with spark, implying reduced memory usage so more tiles can fit in the cache, but it would be helpful to see a before / after table detailing comparisons of on-disk size, in-memory size, and additional parse time required due to transcoding from the library so the tradeoffs are clear.

The overhead of spark.js is fairly low, because transcoding happens on the GPU and the codecs are extremely fast. The bottleneck is usually in the image decoding on the CPU, which is orders of magnitude slower.

I've provided some numbers in previous blog posts, for example, here's a size comparison of the sponza scene:

https://www.ludicon.com/castano/blog/2026/02/an-updated-sponza-gltf/

On that test, enabling or disabling spark did not affect the loading time in a perceptible way.

Here are some more numbers from an earlier release:

https://www.ludicon.com/castano/blog/2025/09/three-js-spark-js/

I should note that spark compression will increase loading time, because the renderer will load many more tiles, so it will increase bandwidth use, but if that's a concern it's possible to control that with the errorTarget parameter.

@gkjohnson
Copy link
Copy Markdown
Contributor

gkjohnson commented Mar 9, 2026

I should note that spark compression will increase loading time, because the renderer will load many more tiles, so it will increase bandwidth use, but if that's a concern it's possible to control that with the errorTarget parameter.

The max memory limit in the LRUCache shouldn't really typically be limiting the tiles that the renderer is determining to be needed unless "errorTarget" is specifically being picked to create the issue. I assume you were reducing the "errorTarget" value (or lru cache memory cap) to a point where there amount of tiles loaded was being limited by memory. This shouldn't be a typical problem, though - noting that it's still a benefit to have more memory overhead where possible. Am I misunderstanding?

@castano
Copy link
Copy Markdown
Contributor Author

castano commented Mar 10, 2026

The max memory limit in the LRUCache shouldn't really typically be limiting the tiles that the renderer is determining to be needed unless "errorTarget" is specifically being picked to create the issue. I assume you were reducing the "errorTarget" value (or lru cache memory cap) to a point where there amount of tiles loaded was being limited by memory. This shouldn't be a typical problem, though - noting that it's still a benefit to have more memory overhead where possible. Am I misunderstanding?

That's not what I see on my end. At errorTarget=16 I often see loading limited by the memory cap. With spark.js the cap is usually not reached, which is why the results have greater detail. Lowering the errorTarget certainly makes the difference more stark.

@castano
Copy link
Copy Markdown
Contributor Author

castano commented Mar 10, 2026

For example, in the following screenshot memory caps at 275 MB:
Screenshot 2026-03-10 at 1 27 19 PM

With spark enabled, memory use goes down to 113 MB, even though detail is noticeably higher:
Screenshot 2026-03-10 at 1 27 02 PM

@gkjohnson
Copy link
Copy Markdown
Contributor

That's not what I see on my end. At errorTarget=16 I often see loading limited by the memory cap.

You're right, I was misremembering. I'm not reaching the cap with the default 20 error target in the demo but it does reach it with 16. With the updated load strategy ("TilesRenderer.optimizedLoadStrategy = true", which will be changed to the default at some point) the number of tiles is reduced by ~20% in the Google tiles case but I agree more overhead is always better.

castano and others added 2 commits March 16, 2026 18:00
Update googleMapsAerial example to enable spark through the plugins argument.
Require spark 0.1.3
Comment thread package.json
Comment on lines -123 to +124
"three": ">=0.167.0"
"three": ">=0.182.0"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't change the peer dependency requirements for the project

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll need to double check whether the required three.js features are all present in that older version, and if they don't gracefully handle the error. It may just work, since most of the three.js changes were in the WebGPU backend, and to add better support for normal maps, which these demos don't need.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spark.js is not a dependency or requirement of this project so the peer dependency should not change - users can use and install 3d-tiles-renderer with r167+ just fine. Spark.js will need to specify it's own three.js dependency limit so if users install Spark that peer dependency version will need to be respected.

Comment thread example/three/googleMapsAerial.js
Comment thread example/three/googleMapsAerial.js
@gkjohnson
Copy link
Copy Markdown
Contributor

@castano somewhat offtopic for this PR but is it possible for spark.js to transcode a "Canvas" element or "SVG" element used for a texture to a more memory efficient GPU format? Some of the plugins are using Canvas to draw vector graphics (like for GeoJSON or other formats) or compose multiple tiled image textures so we don't have a traditional image handle to process in these cases but it affording memory improvements would be nice.

@castano
Copy link
Copy Markdown
Contributor Author

castano commented Mar 30, 2026

somewhat offtopic for this PR but is it possible for spark.js to transcode a "Canvas" element or "SVG" element used for a texture to a more memory efficient GPU format? Some of the plugins are using Canvas to draw vector graphics (like for GeoJSON or other formats) or compose multiple tiled image textures so we don't have a traditional image handle to process in these cases but it affording memory improvements would be nice.

Canvas and SVG elements should work fine, but I have not tested that code path under WebGL. You can find a
WebGPU SVG example in the spark.js SDK:

https://github.com/Ludicon/spark.js/blob/b01b03fd4cd19d70a0c3ea977e348c416e6700c8/examples/svg.html

Examples are automatically published at:

https://ludicon.github.io/spark.js/

I'll look into testing the canvas and SVG code paths under WebGL, but even if it doesn't work now, it shouldn't be hard to support that.

If you can point me to the examples that could benefit from that support I can take a look and ensure it works for those use cases.

@gkjohnson
Copy link
Copy Markdown
Contributor

gkjohnson commented Mar 31, 2026

Canvas and SVG elements should work fine, but I have not tested that code path under WebGL. You can find a
WebGPU SVG example in the spark.js SDK:

https://github.com/Ludicon/spark.js/blob/b01b03fd4cd19d70a0c3ea977e348c416e6700c8/examples/svg.html

It looks like this is passing the SVG path as a string but I expect it will work just fine if you pass the SVG as an image element, as well:

const svg = new Image();
svg.src = './assets/Tiger.svg';

const texture = await spark.encodeTexture( svg, { format: "rgba" } );

--

Regarding canvas, though - it looks like the "encodeTexture" function does not take an HTMLCanvasElement (or OffscreenCanvas) as an argument, which is why I ask:

source (string | HTMLImageElement | ImageBitmap | GPUtexture)
The image to encode. Can be a GPUTexture, URL, DOM image or ImageBitmap.

The "ImageOverlayPlugins", which is used in "quantMeshOverlays" demo, composes tiled textures for overlays. I'll have to think through how something like spark can be integrated, though.

@castano
Copy link
Copy Markdown
Contributor Author

castano commented Apr 14, 2026

Regarding canvas, though - it looks like the "encodeTexture" function does not take an HTMLCanvasElement (or OffscreenCanvas) as an argument

Right. HTMLCanvasElement and OffscreenCanvas did actually work already, but did not want to document that since I had never tested it. I've updated the docs and added an example to validate that code path:

Ludicon/spark.js#30

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants