Add support for experimental WESL shader source (#17953)

# Objective

WESL's pre-MVP `0.1.0` has been
[released](https://docs.rs/wesl/latest/wesl/)!

Add support for WESL shader source so that we can begin playing and
testing WESL, as well as aiding in their development.

## Solution

Adds a `ShaderSource::WESL` that can be used to load `.wesl` shaders.

Right now, we don't support mixing `naga-oil`. Additionally, WESL
shaders currently need to pass through the naga frontend, which the WESL
team is aware isn't great for performance (they're working on compiling
into naga modules). Also, since our shaders are managed using the asset
system, we don't currently support using file based imports like `super`
or package scoped imports. Further work will be needed to asses how we
want to support this.

---

## Showcase

See the `shader_material_wesl` example. Be sure to press space to
activate party mode (trigger conditional compilation)!


https://github.com/user-attachments/assets/ec6ad19f-b6e4-4e9d-a00f-6f09336b08a4
This commit is contained in:
charlotte 2025-03-09 12:26:55 -07:00 committed by GitHub
parent c14733d177
commit 181445c56b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 328 additions and 2 deletions

View File

@ -442,6 +442,9 @@ shader_format_glsl = ["bevy_internal/shader_format_glsl"]
# Enable support for shaders in SPIR-V
shader_format_spirv = ["bevy_internal/shader_format_spirv"]
# Enable support for shaders in WESL
shader_format_wesl = ["bevy_internal/shader_format_wesl"]
# Enable support for transmission-related textures in the `StandardMaterial`, at the risk of blowing past the global, per-shader texture limit on older/lower-end GPUs
pbr_transmission_textures = ["bevy_internal/pbr_transmission_textures"]
@ -2764,6 +2767,18 @@ description = "A shader that uses the GLSL shading language"
category = "Shaders"
wasm = true
[[example]]
name = "shader_material_wesl"
path = "examples/shader/shader_material_wesl.rs"
doc-scrape-examples = true
required-features = ["shader_format_wesl"]
[package.metadata.example.shader_material_wesl]
name = "Material - WESL"
description = "A shader that uses WESL"
category = "Shaders"
wasm = true
[[example]]
name = "custom_shader_instancing"
path = "examples/shader/custom_shader_instancing.rs"

View File

@ -0,0 +1,19 @@
import super::shaders::util::make_polka_dots;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(2) uv: vec2<f32>,
}
struct CustomMaterial {
time: f32,
}
@group(2) @binding(0) var<uniform> material: CustomMaterial;
@fragment
fn fragment(
mesh: VertexOutput,
) -> @location(0) vec4<f32> {
return make_polka_dots(mesh.uv, material.time);
}

29
assets/shaders/util.wesl Normal file
View File

@ -0,0 +1,29 @@
fn make_polka_dots(pos: vec2<f32>, time: f32) -> vec4<f32> {
// Create repeating circles
let scaled_pos = pos * 6.0;
let cell = vec2<f32>(fract(scaled_pos.x), fract(scaled_pos.y));
let dist_from_center = distance(cell, vec2<f32>(0.5));
// Make dots alternate between pink and purple
let is_even = (floor(scaled_pos.x) + floor(scaled_pos.y)) % 2.0;
var dot_color = vec3<f32>(0.0);
@if(!PARTY_MODE) {
let color1 = vec3<f32>(1.0, 0.4, 0.8); // pink
let color2 = vec3<f32>(0.6, 0.2, 1.0); // purple
dot_color = mix(color1, color2, is_even);
}
// Animate the colors in party mode
@if(PARTY_MODE) {
let color1 = vec3<f32>(1.0, 0.2, 0.2); // red
let color2 = vec3<f32>(0.2, 0.2, 1.0); // blue
let oscillation = (sin(time * 10.0) + 1.0) * 0.5;
let animated_color1 = mix(color1, color2, oscillation);
let animated_color2 = mix(color2, color1, oscillation);
dot_color = mix(animated_color1, animated_color2, is_even);
}
// Draw the dot
let is_dot = step(dist_from_center, 0.3);
return vec4<f32>(dot_color * is_dot, is_dot);
}

View File

@ -88,6 +88,7 @@ shader_format_glsl = [
"bevy_pbr?/shader_format_glsl",
]
shader_format_spirv = ["bevy_render/shader_format_spirv"]
shader_format_wesl = ["bevy_render/shader_format_wesl"]
serialize = [
"bevy_a11y?/serialize",

View File

@ -20,6 +20,7 @@ multi_threaded = ["bevy_tasks/multi_threaded"]
shader_format_glsl = ["naga/glsl-in", "naga/wgsl-out", "naga_oil/glsl"]
shader_format_spirv = ["wgpu/spirv", "naga/spv-in", "naga/spv-out"]
shader_format_wesl = ["wesl"]
# Enable SPIR-V shader passthrough
spirv_shader_passthrough = ["wgpu/spirv"]
@ -100,6 +101,7 @@ tracing = { version = "0.1", default-features = false, features = ["std"] }
indexmap = { version = "2" }
fixedbitset = { version = "0.5" }
bitflags = "2"
wesl = { version = "0.1.0", optional = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Omit the `glsl` feature in non-WebAssembly by default.

View File

@ -127,6 +127,8 @@ struct ShaderData {
struct ShaderCache {
data: HashMap<AssetId<Shader>, ShaderData>,
#[cfg(feature = "shader_format_wesl")]
asset_paths: HashMap<wesl::syntax::ModulePath, AssetId<Shader>>,
shaders: HashMap<AssetId<Shader>, Shader>,
import_path_shaders: HashMap<ShaderImport, AssetId<Shader>>,
waiting_on_import: HashMap<ShaderImport, Vec<AssetId<Shader>>>,
@ -179,6 +181,8 @@ impl ShaderCache {
Self {
composer,
data: Default::default(),
#[cfg(feature = "shader_format_wesl")]
asset_paths: Default::default(),
shaders: Default::default(),
import_path_shaders: Default::default(),
waiting_on_import: Default::default(),
@ -223,6 +227,7 @@ impl ShaderCache {
.shaders
.get(&id)
.ok_or(PipelineCacheError::ShaderNotLoaded(id))?;
let data = self.data.entry(id).or_default();
let n_asset_imports = shader
.imports()
@ -267,6 +272,44 @@ impl ShaderCache {
let shader_source = match &shader.source {
#[cfg(feature = "shader_format_spirv")]
Source::SpirV(data) => make_spirv(data),
#[cfg(feature = "shader_format_wesl")]
Source::Wesl(_) => {
if let ShaderImport::AssetPath(path) = shader.import_path() {
let shader_resolver =
ShaderResolver::new(&self.asset_paths, &self.shaders);
let module_path = wesl::syntax::ModulePath::from_path(path);
let mut compiler_options = wesl::CompileOptions {
imports: true,
condcomp: true,
lower: true,
..default()
};
for shader_def in shader_defs {
match shader_def {
ShaderDefVal::Bool(key, value) => {
compiler_options.features.insert(key.clone(), value);
}
_ => debug!(
"ShaderDefVal::Int and ShaderDefVal::UInt are not supported in wesl",
),
}
}
let compiled = wesl::compile(
&module_path,
&shader_resolver,
&wesl::EscapeMangler,
&compiler_options,
)
.unwrap();
let naga = naga::front::wgsl::parse_str(&compiled.to_string()).unwrap();
ShaderSource::Naga(Cow::Owned(naga))
} else {
panic!("Wesl shaders must be imported from a file");
}
}
#[cfg(not(feature = "shader_format_spirv"))]
Source::SpirV(_) => {
unimplemented!(
@ -398,6 +441,13 @@ impl ShaderCache {
}
}
#[cfg(feature = "shader_format_wesl")]
if let Source::Wesl(_) = shader.source {
if let ShaderImport::AssetPath(path) = shader.import_path() {
self.asset_paths
.insert(wesl::syntax::ModulePath::from_path(path), id);
}
}
self.shaders.insert(id, shader);
pipelines_to_queue
}
@ -412,6 +462,40 @@ impl ShaderCache {
}
}
#[cfg(feature = "shader_format_wesl")]
pub struct ShaderResolver<'a> {
asset_paths: &'a HashMap<wesl::syntax::ModulePath, AssetId<Shader>>,
shaders: &'a HashMap<AssetId<Shader>, Shader>,
}
#[cfg(feature = "shader_format_wesl")]
impl<'a> ShaderResolver<'a> {
pub fn new(
asset_paths: &'a HashMap<wesl::syntax::ModulePath, AssetId<Shader>>,
shaders: &'a HashMap<AssetId<Shader>, Shader>,
) -> Self {
Self {
asset_paths,
shaders,
}
}
}
#[cfg(feature = "shader_format_wesl")]
impl<'a> wesl::Resolver for ShaderResolver<'a> {
fn resolve_source(
&self,
module_path: &wesl::syntax::ModulePath,
) -> Result<Cow<str>, wesl::ResolveError> {
let asset_id = self.asset_paths.get(module_path).ok_or_else(|| {
wesl::ResolveError::ModuleNotFound(module_path.clone(), "Invalid asset id".to_string())
})?;
let shader = self.shaders.get(asset_id).unwrap();
Ok(Cow::Borrowed(shader.source.as_str()))
}
}
type LayoutCacheKey = (Vec<BindGroupLayoutId>, Vec<PushConstantRange>);
#[derive(Default)]
struct LayoutCache {

View File

@ -155,6 +155,49 @@ impl Shader {
}
}
#[cfg(feature = "shader_format_wesl")]
pub fn from_wesl(source: impl Into<Cow<'static, str>>, path: impl Into<String>) -> Shader {
let source = source.into();
let path = path.into();
let (import_path, imports) = Shader::preprocess(&source, &path);
match import_path {
ShaderImport::AssetPath(asset_path) => {
let asset_path = std::path::PathBuf::from(&asset_path);
// Resolve and normalize the path
let asset_path = asset_path.canonicalize().unwrap_or(asset_path);
// Strip the asset root
let mut base_path = bevy_asset::io::file::FileAssetReader::get_base_path();
// TODO: integrate better with the asset system rather than hard coding this
base_path.push("assets");
let asset_path = asset_path
.strip_prefix(&base_path)
.unwrap_or_else(|_| &asset_path);
// Wesl paths are provided as absolute relative to the asset root
let asset_path = std::path::Path::new("/").join(asset_path);
// And with a striped file name
let asset_path = asset_path.with_extension("");
let asset_path = asset_path.to_str().unwrap_or_else(|| {
panic!("Failed to convert path to string: {:?}", asset_path)
});
let import_path = ShaderImport::AssetPath(asset_path.to_string());
Shader {
path,
imports,
import_path,
source: Source::Wesl(source),
additional_imports: Default::default(),
shader_defs: Default::default(),
file_dependencies: Default::default(),
validate_shader: ValidateShader::Disabled,
}
}
ShaderImport::Custom(_) => {
panic!("Wesl shaders must be imported from an asset path");
}
}
}
pub fn set_import_path<P: Into<String>>(&mut self, import_path: P) {
self.import_path = ShaderImport::Custom(import_path.into());
}
@ -223,6 +266,7 @@ impl<'a> From<&'a Shader> for naga_oil::compose::NagaModuleDescriptor<'a> {
#[derive(Debug, Clone)]
pub enum Source {
Wgsl(Cow<'static, str>),
Wesl(Cow<'static, str>),
Glsl(Cow<'static, str>, naga::ShaderStage),
SpirV(Cow<'static, [u8]>),
// TODO: consider the following
@ -233,7 +277,7 @@ pub enum Source {
impl Source {
pub fn as_str(&self) -> &str {
match self {
Source::Wgsl(s) | Source::Glsl(s, _) => s,
Source::Wgsl(s) | Source::Wesl(s) | Source::Glsl(s, _) => s,
Source::SpirV(_) => panic!("spirv not yet implemented"),
}
}
@ -250,6 +294,7 @@ impl From<&Source> for naga_oil::compose::ShaderLanguage {
"GLSL is not supported in this configuration; use the feature `shader_format_glsl`"
),
Source::SpirV(_) => panic!("spirv not yet implemented"),
Source::Wesl(_) => panic!("wesl not yet implemented"),
}
}
}
@ -269,6 +314,7 @@ impl From<&Source> for naga_oil::compose::ShaderType {
"GLSL is not supported in this configuration; use the feature `shader_format_glsl`"
),
Source::SpirV(_) => panic!("spirv not yet implemented"),
Source::Wesl(_) => panic!("wesl not yet implemented"),
}
}
}
@ -312,6 +358,8 @@ impl AssetLoader for ShaderLoader {
"comp" => {
Shader::from_glsl(String::from_utf8(bytes)?, naga::ShaderStage::Compute, path)
}
#[cfg(feature = "shader_format_wesl")]
"wesl" => Shader::from_wesl(String::from_utf8(bytes)?, path),
_ => panic!("unhandled extension: {ext}"),
};
@ -325,7 +373,7 @@ impl AssetLoader for ShaderLoader {
}
fn extensions(&self) -> &[&str] {
&["spv", "wgsl", "vert", "frag", "comp"]
&["spv", "wgsl", "vert", "frag", "comp", "wesl"]
}
}

View File

@ -102,6 +102,7 @@ The default feature set enables most of the expected features of a game engine,
|serialize|Enable serialization support through serde|
|shader_format_glsl|Enable support for shaders in GLSL|
|shader_format_spirv|Enable support for shaders in SPIR-V|
|shader_format_wesl|Enable support for shaders in WESL|
|spirv_shader_passthrough|Enable passthrough loading for SPIR-V shaders (Only supported on Vulkan, shader capabilities and extensions must agree with the platform implementation)|
|statically-linked-dxc|Statically linked DXC shader compiler for DirectX 12|
|symphonia-aac|AAC audio format support (through symphonia)|

View File

@ -447,6 +447,7 @@ Example | Description
[Material - Bindless](../examples/shader/shader_material_bindless.rs) | Demonstrates how to make materials that use bindless textures
[Material - GLSL](../examples/shader/shader_material_glsl.rs) | A shader that uses the GLSL shading language
[Material - Screenspace Texture](../examples/shader/shader_material_screenspace_texture.rs) | A shader that samples a texture with view-independent UV coordinates
[Material - WESL](../examples/shader/shader_material_wesl.rs) | A shader that uses WESL
[Material Prepass](../examples/shader/shader_prepass.rs) | A shader that uses the various textures generated by the prepass
[Post Processing - Custom Render Pass](../examples/shader/custom_post_processing.rs) | A custom post processing effect, using a custom render pass that runs after the main pass
[Shader Defs](../examples/shader/shader_defs.rs) | A shader that uses "shaders defs" (a bevy tool to selectively toggle parts of a shader)

View File

@ -0,0 +1,126 @@
//! A shader that uses the WESL shading language.
use bevy::{
asset::{load_internal_asset, weak_handle},
pbr::{MaterialPipeline, MaterialPipelineKey},
prelude::*,
reflect::TypePath,
render::{
mesh::MeshVertexBufferLayoutRef,
render_resource::{
AsBindGroup, RenderPipelineDescriptor, ShaderDefVal, ShaderRef,
SpecializedMeshPipelineError,
},
},
};
/// This example uses shader source files from the assets subdirectory
const FRAGMENT_SHADER_ASSET_PATH: &str = "shaders/custom_material.wesl";
/// An example utility shader that is used by the custom material
pub const UTIL_SHADER_HANDLE: Handle<Shader> = weak_handle!("748706a1-969e-43d4-be36-74559bd31d23");
fn main() {
App::new()
.add_plugins((
DefaultPlugins,
MaterialPlugin::<CustomMaterial>::default(),
CustomMaterialPlugin,
))
.add_systems(Startup, setup)
.add_systems(Update, update)
.run();
}
/// A plugin that loads the custom material shader
pub struct CustomMaterialPlugin;
impl Plugin for CustomMaterialPlugin {
fn build(&self, app: &mut App) {
load_internal_asset!(
app,
UTIL_SHADER_HANDLE,
"../../assets/shaders/util.wesl",
Shader::from_wesl
);
}
}
/// set up a simple 3D scene
fn setup(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<CustomMaterial>>,
) {
// cube
commands.spawn((
Mesh3d(meshes.add(Cuboid::default())),
MeshMaterial3d(materials.add(CustomMaterial {
time: 0.0,
party_mode: false,
})),
Transform::from_xyz(0.0, 0.5, 0.0),
));
// camera
commands.spawn((
Camera3d::default(),
Transform::from_xyz(-2.0, 2.5, 5.0).looking_at(Vec3::ZERO, Vec3::Y),
));
}
fn update(
time: Res<Time>,
mut query: Query<&MeshMaterial3d<CustomMaterial>>,
mut materials: ResMut<Assets<CustomMaterial>>,
keys: Res<ButtonInput<KeyCode>>,
) {
for material in query.iter_mut() {
let material = materials.get_mut(material).unwrap();
material.time = time.elapsed_secs();
if keys.just_pressed(KeyCode::Space) {
material.party_mode = !material.party_mode;
}
}
}
// This is the struct that will be passed to your shader
#[derive(Asset, TypePath, AsBindGroup, Clone)]
#[bind_group_data(CustomMaterialKey)]
struct CustomMaterial {
#[uniform(0)]
time: f32,
party_mode: bool,
}
#[derive(Eq, PartialEq, Hash, Clone)]
struct CustomMaterialKey {
party_mode: bool,
}
impl From<&CustomMaterial> for CustomMaterialKey {
fn from(material: &CustomMaterial) -> Self {
Self {
party_mode: material.party_mode,
}
}
}
impl Material for CustomMaterial {
fn fragment_shader() -> ShaderRef {
FRAGMENT_SHADER_ASSET_PATH.into()
}
fn specialize(
_pipeline: &MaterialPipeline<Self>,
descriptor: &mut RenderPipelineDescriptor,
_layout: &MeshVertexBufferLayoutRef,
key: MaterialPipelineKey<Self>,
) -> Result<(), SpecializedMeshPipelineError> {
let fragment = descriptor.fragment.as_mut().unwrap();
fragment.shader_defs.push(ShaderDefVal::Bool(
"PARTY_MODE".to_string(),
key.bind_group_data.party_mode,
));
Ok(())
}
}