diff --git a/docs/dev/superpowers/specs/2026-08-24-webgpu-vulkan-render-parity.md b/docs/dev/superpowers/specs/2026-08-24-webgpu-vulkan-render-parity.md index 1d0ec187..06c14a2d 100644 --- a/docs/dev/superpowers/specs/2026-08-24-webgpu-vulkan-render-parity.md +++ b/docs/dev/superpowers/specs/2026-08-24-webgpu-vulkan-render-parity.md @@ -50,6 +50,7 @@ and `test gap`. | Hair/custom hair shaders | supported for built-in hair | Anisotropic built-in WGSL shading is available; arbitrary SPIR-V hair remains an intentional input-language difference | Define a portable custom WGSL/SPIR-V shader-pair contract | | Grass built-in | partial, geometry verified | WGSL supports billboard expansion, authored sway atlases, alpha cutout and light/dark layers; Dawn shadow lighting distribution still differs from Vulkan | Converge grass receiver bias and lighting image thresholds | | Weather precipitation / lightning | supported, API verified | Native WGSL mirrors camera-centred rain/snow recycling, wind billboards, density/fog filtering and dithered lightning; Vulkan retains packaged SPIR-V | Add deterministic rain/snow/lightning image artifacts | +| SpriteStack 3D / batch | supported, verified | WebGPU now uses the stack's own alpha-blended WGSL card shader, including atlas UV rect and alpha cutoff, rather than silently substituting the unrelated built-in hair shader | Extend artifacts to CSM shadow and outline combinations | | Font rendering | supported | Native and browser WebGPU render supplied font atlases; browser builds do not synthesize atlases with FreeType | Add packaged-atlas browser examples | | GPU-driven/indirect/visibility buffer | supported, verified | WebGPU compute-compacts 256-byte-aligned per-bucket model ranges, emits portable indirect commands, writes ID/barycentric visibility attachments and reconstructs the shared PBR inputs in a fullscreen resolve | Extend the parity scene to browser artifacts and mixed opaque/transparent content | | Virtual geometry GPU path | supported, verified | WebGPU uses compute-written per-cluster indirect commands (`instanceCount=0` for culled clusters) because portable WebGPU does not guarantee Vulkan's indirect-count draw; both feed the same ID/bary visibility semantics and flat-material resolve | Add DAG LOD/HZB cluster occlusion and large multi-asset artifacts | diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index efdc7b0e..e164097f 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -1028,6 +1028,9 @@ public: */ virtual Shader *newHairShaderFromSpv(const std::vector &vertSpv, const std::vector &fragSpv) = 0; + /** @brief Create an alpha-blended hair/card shader from WGSL on WebGPU. */ + virtual Shader *newHairShaderFromWgsl(const std::string &vertWgsl, + const std::string &fragWgsl) = 0; /** @brief Built-in hair shader with default anisotropic parameters. */ Shader *newHairShader(); diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 135e092e..598c39a9 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -340,6 +340,8 @@ public: Shader *newMeshShader(const std::string &vertGlsl, const std::string &fragGlsl) override; Shader *newHairShaderFromSpv(const std::vector &vertSpv, const std::vector &fragSpv) override; + Shader *newHairShaderFromWgsl(const std::string &vertWgsl, + const std::string &fragWgsl) override; bool releaseShader(Shader *shader) override; Mesh *newMeshFromAssimp(const ::aiMesh &mesh) override; Mesh *newMeshFromAssimp(const ::aiMesh &mesh, const aiMatrix4x4 &worldTransform) override; diff --git a/src/modules/graphics/vulkan/Graphics2D.cpp b/src/modules/graphics/vulkan/Graphics2D.cpp index e8586d5b..b33a6254 100644 --- a/src/modules/graphics/vulkan/Graphics2D.cpp +++ b/src/modules/graphics/vulkan/Graphics2D.cpp @@ -1031,6 +1031,10 @@ Shader *Graphics::newHairShaderFromSpv(const std::vector &vertSpv, return raw; } +Shader *Graphics::newHairShaderFromWgsl(const std::string &, const std::string &) { + throw Exception("newHairShaderFromWgsl: WGSL is only supported on the WebGPU backend"); +} + bool Graphics::releaseShader(Shader *shader) { if (!shader || !shader->gpuHandle) return false; diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index da3cdfd9..af197f62 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -5280,4 +5280,25 @@ Shader *Graphics::newHairShaderFromSpv(const std::vector &vertSpv, return raw; } +Shader *Graphics::newHairShaderFromWgsl(const std::string &vertWgsl, + const std::string &fragWgsl) { + if (!device) throw Exception("newHairShaderFromWgsl: device not initialized"); + if (fragWgsl.empty()) throw Exception("newHairShaderFromWgsl: empty fragment WGSL"); + auto gpu = std::make_unique(); + gpu->isMesh3D = true; + gpu->isHair3D = true; + gpu->wgslVert = vertWgsl.empty() ? kMesh3DVertWgsl : vertWgsl; + gpu->wgslFrag = fragWgsl; + gpu->mesh3dPipeline = buildPipelineFromWgsl( + device, mesh3dPipelineLayout, sceneColorFormat, gpu->wgslVert, gpu->wgslFrag, + true, true, true, true, false, false, sceneColorSamples); + auto shader = std::make_unique(); + shader->setKind(Shader::Kind::eMesh3D); + shader->gpuHandle = gpu.get(); + Shader *raw = shader.get(); + ownedShaders.push_back(std::move(shader)); + ownedGpuShaders.push_back(std::move(gpu)); + return raw; +} + } // namespace eve::graphics::webgpu diff --git a/src/modules/graphics/webgpu/Graphics.h b/src/modules/graphics/webgpu/Graphics.h index 54a19fa1..bc30fa4e 100644 --- a/src/modules/graphics/webgpu/Graphics.h +++ b/src/modules/graphics/webgpu/Graphics.h @@ -240,6 +240,8 @@ public: Shader *newMeshShader(const std::string &vertGlsl, const std::string &fragGlsl) override; Shader *newHairShaderFromSpv(const std::vector &vertSpv, const std::vector &fragSpv) override; + Shader *newHairShaderFromWgsl(const std::string &vertWgsl, + const std::string &fragWgsl) override; bool releaseShader(Shader *shader) override; Mesh *newMeshFromAssimp(const ::aiMesh &mesh) override; diff --git a/src/modules/spritestack/SpriteStack.cpp b/src/modules/spritestack/SpriteStack.cpp index cace5c2b..e33a3bf7 100644 --- a/src/modules/spritestack/SpriteStack.cpp +++ b/src/modules/spritestack/SpriteStack.cpp @@ -1,4 +1,5 @@ #include "spritestack/SpriteStack.h" +#include "spritestack/shaders/SpriteStackWgsl.h" #include "common/Exception.h" #include "common/ECS.h" @@ -604,7 +605,9 @@ void SpriteStack3D::ensureResources(graphics::Graphics *gfx) const { if (!shader_) { auto vert = copySpv(sprite_stack_vert_spv, sprite_stack_vert_spv_count); auto frag = copySpv(sprite_stack_frag_spv, sprite_stack_frag_spv_count); - shader_ = gfx->newHairShaderFromSpv(vert, frag); + shader_ = gfx->getBackendName() == "webgpu" + ? gfx->newHairShaderFromWgsl(shaders::kVert, shaders::kFrag) + : gfx->newHairShaderFromSpv(vert, frag); if (!shader_ || !shader_->gpuHandle) throw eve::Exception("SpriteStack3D.render: failed to create slice shader"); shader_->declareVec4("uvRect"); @@ -901,7 +904,9 @@ void SpriteStackBatch::ensureShader(graphics::Graphics *gfx) { if (shader_) return; auto vert = copySpv(sprite_stack_vert_spv, sprite_stack_vert_spv_count); auto frag = copySpv(sprite_stack_frag_spv, sprite_stack_frag_spv_count); - shader_ = gfx->newHairShaderFromSpv(vert, frag); + shader_ = gfx->getBackendName() == "webgpu" + ? gfx->newHairShaderFromWgsl(shaders::kVert, shaders::kFrag) + : gfx->newHairShaderFromSpv(vert, frag); if (!shader_ || !shader_->gpuHandle) throw eve::Exception("SpriteStackBatch.render: failed to create slice shader"); shader_->declareVec4("uvRect"); diff --git a/src/modules/spritestack/shaders/SpriteStackWgsl.h b/src/modules/spritestack/shaders/SpriteStackWgsl.h new file mode 100644 index 00000000..af88c5d4 --- /dev/null +++ b/src/modules/spritestack/shaders/SpriteStackWgsl.h @@ -0,0 +1,9 @@ +#pragma once +namespace eve::spritestack::shaders { +inline constexpr const char *kVert = R"wgsl( +struct Light{posRadius:vec4f,color:vec4f};struct Frame{mvp:mat4x4f,model:mat4x4f,lightDir:vec4f,lightColor:vec4f,tint:vec4f,cameraPos:vec4f,ambient:vec4f,lights:array,texBomb:vec4f,parallax:vec4f,surface:vec4f,view:mat4x4f,clipInfo:vec4f,cloud:vec4f,cloudWind:vec4f};struct Params{data:array};struct In{@location(0) pos:vec3f,@location(1) normal:vec3f,@location(2) uv:vec2f};struct Out{@builtin(position) pos:vec4f,@location(1) uv:vec2f,@location(2) tint:vec4f};@group(0) @binding(0)var frame:Frame;@group(0) @binding(15)var params:Params;@vertex fn vs_main(i:In)->Out{var o:Out;o.pos=frame.mvp*vec4f(i.pos,1);o.pos.y=-o.pos.y;let r=params.data[0];o.uv=r.xy+i.uv*(r.zw-r.xy);o.tint=frame.tint;return o;} +)wgsl"; +inline constexpr const char *kFrag = R"wgsl( +struct Params{data:array};struct In{@location(1) uv:vec2f,@location(2) tint:vec4f};@group(0) @binding(1)var tex:texture_2d;@group(0) @binding(7)var samp:sampler;@group(0) @binding(15)var params:Params;@fragment fn fs_main(i:In)->@location(0) vec4f{let c=textureSample(tex,samp,i.uv)*i.tint;if(c.a