Add spark.js support to three.js 3DTilesRenderer#1497
Conversation
|
Hello! Can you provide some more context for this PR? |
|
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. |
There was a problem hiding this comment.
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.
| // IC: The code below assumes tex was created from an ImageBitmap | ||
| if ( tex instanceof ExternalTexture && tex.userData?.byteLength ) { | ||
|
|
||
| return tex.userData.byteLength; | ||
|
|
||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
UnloadTilesPluginis 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.
There was a problem hiding this comment.
... 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:
- It feels unexpected that the user must store the WebGL context on the texture
- 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.
There was a problem hiding this comment.
@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).
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
OK, here's what I have:
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.
There was a problem hiding this comment.
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:
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.
That's exactly right.
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 |
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. |
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. |
Update googleMapsAerial example to enable spark through the plugins argument. Require spark 0.1.3
| "three": ">=0.167.0" | ||
| "three": ">=0.182.0" |
There was a problem hiding this comment.
We shouldn't change the peer dependency requirements for the project
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
@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. |
Canvas and SVG elements should work fine, but I have not tested that code path under WebGL. You can find a 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. |
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
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. |
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: |


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/