Skip to content

Fix spurious 404s when loading WESL shader imports on the web - #25783

Closed
taearls wants to merge 8 commits into
bevyengine:mainfrom
taearls:fix/25363-resolver-driven
Closed

taearls wants to merge 8 commits into
bevyengine:mainfrom
taearls:fix/25363-resolver-driven

Conversation

@taearls

@taearls taearls commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Disclosure: I used Claude to assist with research into how to address this issue. Working with WESL and shader imports is new for me so I felt like it was justified to use it for assistance while I formulated a solution.

The initial version of this PR had some documenting comments that Claude assisted with because I did not feel confident about it. This was a mistake. I've since edited my comments by hand for clarity and brevity.

Objective

GET /assets/shaders/custom_material_import/COLOR_MULTIPLIER.wesl 404

COLOR_MULTIPLIER is a const inside custom_material_import.wesl, not a module beneath it. Rendering is fine, but the error is indistinguishable from a broken asset and fires for every item import in every WESL shader.

import a::b::C is ambiguous: C may be a file a/b/C.wesl or a declaration inside a/b.wesl, and the import statement can't distinguish them. scan_wesl_imports emitted both readings as candidates, and AssetLoader::load chose between them by reading each from storage and keeping whichever succeeded — so one read per import was designed to miss.

When Bevy apps run natively, that's a silent failed stat; on the web, it surfaces in the Network tab of the Developer tools as an HTTP GET, and the browser logs the 404 before the response reaches Rust, so we can't suppress it.

Solution

Resolve ambiguities with wesl imports during compile time instead of guessing during run time. wesl resolves imports at the point of use, where the spec makes them unambiguous, so it only requests modules that exist — and it reports a module it can't find as structured data (ResolveError::ModuleNotFound(ModulePath, _)).

  • The asset loader no longer collects WESL dependencies. No speculative candidate, no probe.
  • ShaderCache keeps the reported ModulePath instead of discarding it. is_module_not_found already matched that exact variant; it now returns the path, carried on ShaderImportNotYetAvailable.
  • PipelineCache loads it, filtered by origin. PathOrigin::Absolute is a file under assets/; a package origin is an engine shader embedded via load_shader_library!` and must never be fetched, or we'd reintroduce the same failing request for a different path.
  • Each module is requested once, keyed by ShaderImport, since discovery re-reports it on every retry until the asset lands. The stored handle keeps it alive, as Shader::file_dependencies is no longer populated for WESL.
  • A failed load warns once via AssetServer::load_state. An unresolved module is now the normal discovery signal and logs at debug, so without this a typo'd import would fail silently.

Testing

Browser (Chrome, WebGPU) — shader_material built with cargo r -p build-wasm-example -- shader_material --api webgpu:

Request Before After
custom_material_import/COLOR_MULTIPLIER.wesl 404 ×2 absent
custom_material.wesl 200 ×2 200 ×1
custom_material_import.wesl 200 ×1 200 ×1

Unit tests — cargo test -p bevy_shader. These three unit tests are new, each confirmed to fail when the behavior it covers is broken:

  • item_import_names_only_its_parent_module — reconstructs the URL from the issue and asserts it's absent.
  • missing_import_names_the_module_to_load — the unresolved module reaches the caller. Uses the existing fixture, whose root reaches bevy_render::maths through an inline path with no import statement — a case no scan of import statements could find.
  • module_path_origin_decides_whether_it_is_fetchable — pins the origin mapping. Breaking it yields Some(AssetPath("/lighting"))instead ofSome(Custom(..))`, which is the request that would 404.

Reproducing, before and after:

# Build the example (repeat on `main` and on this branch)
cargo run -p build-wasm-example -- shader_material --api webgpu

# Serve it. Pick a DIFFERENT port for each build you test -- Chrome keys its
# cache by origin, and a reused port serves .wesl from disk cache, showing
# no requests at all and looking like a false pass.
cd examples/wasm && python3 -c "
import http.server
class H(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header('Cache-Control', 'no-store')
        super().end_headers()
http.server.test(HandlerClass=H, port=8801, bind='127.0.0.1')"

Open http://localhost:8801/index.html and keep the tab focused — a throttled background tab pauses the frame loop that drives discovery, so requests stall and the page looks broken. The server's access log is the easiest place to read the result:

  • on main: two 404s for custom_material_import/COLOR_MULTIPLIER.wesl
  • on this branch: absent, and custom_material_import.wesl fetched once

The cube should render with the bird texture darkened by the imported constant's 0.5 alpha in both cases.

The two *.wesl.meta 404s are unrelated: they appear for any asset in any Bevy web build and are a handled NotFound branch.

Platforms: web (WebGPU, Chrome) and native (macOS/Metal).

Notes for reviewers

  • Layering. PipelineCache is in the render world and now initiates asset loads, where the render world normally only extracts from the main world. AssetServer is already a render world resource so it works, but it's the part I'm least sure about. I prototyped main-world discovery and abandoned it: shader defs live in the render world, so a main-world scan under-reports dependencies reachable only under an @if flag — failing in the dangerous direction.
  • Serialized discovery. wesl reports only the first unresolved module per compile, so an N-deep chain takes N rounds. Imperceptible at the depths here, but linear in chain depth.
  • wesl_module_requests is never pruned. Bounded by distinct modules, so it can't grow without limit, but entries outlive their shaders. Happy to add cleanup on AssetEvent::Removed if that is appropriate. I wanted to keep this PR as minimal as possible.

Not tested

  • Import chains deeper than one level.
  • Hot reloading a shader whose dependencies change.

@taearls taearls added A-Rendering Drawing game state to the screen O-Web Specific to web (WASM) builds O-WebGPU Specific to the WebGPU render API S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 14, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in Rendering Sep 14, 2026
@taearls taearls added the A-UI Graphical user interfaces, styles, layouts, and widgets label Sep 14, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in UI Sep 14, 2026
@rparrett

Copy link
Copy Markdown
Contributor

You may want to refresh yourself on Bevy's AI Policy, it was updated recently.

Comment thread crates/bevy_shader/src/shader.rs Outdated
Comment thread crates/bevy_shader/src/shader.rs Outdated
Comment thread crates/bevy_shader/src/shader_cache.rs Outdated
Comment thread crates/bevy_shader/src/shader_cache.rs Outdated
@alice-i-cecile alice-i-cecile added X-Contentious There are nontrivial implications that should be thought through S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Sep 14, 2026
@taearls

taearls commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the feedback! I apologize sincerely for the initial messy state of the comments in this PR.

Full disclosure: I had used Claude for some initial research into this, to review my work, and to help write some documenting comments. This area of the Bevy code base is new to me, so I had leaned into it to verify my approach and my thinking.

I should have done more due diligence into my own work and into the updated AI policy before opening this PR for review, and I should have disclosed the AI usage in my PR description. I won't do this in the future.

In the interest of full transparency, I updated my PR description with an AI disclosure.

@alice-i-cecile alice-i-cecile left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you please add an AI use disclosure to your PR description so we can review it more accurately?

@taearls

taearls commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Can you please add an AI use disclosure to your PR description so we can review it more accurately?

Done! I put it at the top of the description.

@alice-i-cecile alice-i-cecile added S-Needs-Review Needs reviewer attention (from anyone!) to move forward and removed S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels Sep 15, 2026
@alice-i-cecile
alice-i-cecile self-requested a review September 15, 2026 00:41

@beicause beicause left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for digging into this — the diagnosis looks right to me. import a::b::C can't be disambiguated from the import statement alone, and pre-scanning both readings just to probe one of them is what produced the 404.

My position: this PR introduces more tradeoffs than the bug fix justifies. I don't think this is the right direction:

  1. Asset loading moves into the render world, and the dependency's lifetime and load state leave the asset system along with it. PipelineCache::load_missing_wesl_modules_system now calls asset_server.load() and takes on the dependency's lifetime, which used to be the loader's job. Your review note flags this as the part you're least sure about, and I think that instinct is worth trusting — it's a tradeoff that comes with two consequences. First, wesl_module_requests is never pruned, and the follow-up you mentioned — dropping entries on AssetEvent::Removed — looks fragile to me when it comes to unloading dependencies. Second, because these imports are no longer registered as asset dependencies, DependencyLoadState / RecursiveDependencyLoadState report loaded while an import is still missing, and LoadedWithDependencies fires early. Nothing in the engine relies on that behavior for shaders today, but it's a user-facing semantic getting quietly weaker.

Longer term, I think #10157 (comment) (asset packing with manifests) can address the root cause here: whether a path exists can only be determined by probing for it, and today probing means a request — on the web, a request that is expected to 404. With a manifest that becomes a local lookup, which makes load-time dependency discovery viable again, and makes the machinery above - initiating loads from the render world - unnecessary.

@taearls

taearls commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for digging into this — the diagnosis looks right to me. import a::b::C can't be disambiguated from the import statement alone, and pre-scanning both readings just to probe one of them is what produced the 404.

My position: this PR introduces more tradeoffs than the bug fix justifies. I don't think this is the right direction:

  1. Asset loading moves into the render world, and the dependency's lifetime and load state leave the asset system along with it. PipelineCache::load_missing_wesl_modules_system now calls asset_server.load() and takes on the dependency's lifetime, which used to be the loader's job. Your review note flags this as the part you're least sure about, and I think that instinct is worth trusting — it's a tradeoff that comes with two consequences. First, wesl_module_requests is never pruned, and the follow-up you mentioned — dropping entries on AssetEvent::Removed — looks fragile to me when it comes to unloading dependencies. Second, because these imports are no longer registered as asset dependencies, DependencyLoadState / RecursiveDependencyLoadState report loaded while an import is still missing, and LoadedWithDependencies fires early. Nothing in the engine relies on that behavior for shaders today, but it's a user-facing semantic getting quietly weaker.

Longer term, I think #10157 (comment) (asset packing with manifests) can address the root cause here: whether a path exists can only be determined by probing for it, and today probing means a request — on the web, a request that is expected to 404. With a manifest that becomes a local lookup, which makes load-time dependency discovery viable again, and makes the machinery above - initiating loads from the render world - unnecessary.

Thanks for reviewing this! Yeah, what you're saying makes a lot of sense. I think I'm going to close this PR because I agree that a manifest is what we really need for this to be fixed without introducing trade offs that fight the ECS mechanisms in place.

@taearls taearls closed this Sep 15, 2026
@github-project-automation github-project-automation Bot moved this from Needs SME Triage to Done in UI Sep 15, 2026
@github-project-automation github-project-automation Bot moved this from Needs SME Triage to Done in Rendering Sep 15, 2026
@taearls
taearls deleted the fix/25363-resolver-driven branch September 15, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Rendering Drawing game state to the screen A-UI Graphical user interfaces, styles, layouts, and widgets O-Web Specific to web (WASM) builds O-WebGPU Specific to the WebGPU render API S-Needs-Review Needs reviewer attention (from anyone!) to move forward X-Contentious There are nontrivial implications that should be thought through

Projects

Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

Unresolved WESL import warning on web

4 participants