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 f6ecd9a8..b1d79b7c 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 @@ -44,7 +44,7 @@ and `test gap`. | Global illumination / SSR | supported | Native WGSL SSGI and SSR pipelines consume the parity-locked GBuffer | Extend reflective material/camera-angle artifacts | | Volumetric effects | supported | Native WGSL cloud/fog/froxel/raymarch/post stages are available | Extend low-resolution parameter coverage | | Alpha mask | supported, verified | Runtime backend selection now reliably chooses WGSL on WebGPU and SPIR-V on Vulkan; shared pixel tests cover threshold, softness and inversion | Extend coverage to atlas UVs and tinted translucent colors | -| Water / waterfall built-ins | supported, API verified | WebGPU uses WGSL ports with the same 32-float parameter layout and Vulkan retains the packaged SPIR-V shaders | Add backend-neutral animated surface pixel artifacts | +| Water / waterfall built-ins | supported, verified | WebGPU uses WGSL ports with the same packed parameter layout and Vulkan retains the packaged SPIR-V shaders; shared pixel tests verify animated ripples/reflection plus waterfall flow/foam response | Extend artifacts to SSR and planar-reflection combinations | | Custom 2D/mesh WGSL | partial | WGSL is supported only on WebGPU; Vulkan accepts SPIR-V instead | Use backend-specific shader fixtures with identical semantics | | Runtime GLSL and SPIR-V on browser | intentional difference | Browser WebGPU accepts WGSL, not Vulkan SPIR-V or runtime GLSL | Keep explicit errors; document paired shader assets/toolchain | | Hair/custom hair shaders | supported, explicit language contract | Built-in hair selects the matching WGSL/SPIR-V implementation per backend; WebGPU accepts custom WGSL and explicitly rejects SPIR-V instead of silently substituting the built-in shader | Add paired custom-shader visual artifacts | diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index 57097e12..e7ab2e84 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -225,7 +225,15 @@ void Graphics::initWithWindow(void *nativeWindow) { } void Graphics::createInstanceAndAdapter() { +#if defined(__EMSCRIPTEN__) instance = wgpu::CreateInstance(); +#else + const WGPUInstanceFeatureName requiredFeatures[] = {WGPUInstanceFeatureName_TimedWaitAny}; + WGPUInstanceDescriptor instanceDesc{}; + instanceDesc.requiredFeatureCount = 1; + instanceDesc.requiredFeatures = requiredFeatures; + instance = wgpu::CreateInstance(reinterpret_cast(&instanceDesc)); +#endif if (!instance) throw Exception("WebGPU: wgpuCreateInstance failed"); WGPURequestAdapterOptions opts{}; @@ -3198,24 +3206,19 @@ void Graphics::drawMeshShader(Mesh *mesh, const glm::mat4 &model, Texture *textu d.model = model; d.tint = tint; d.shader = shader; - d.surfaceMode = mesh3dSurfaceMode; - d.surfaceBlend = mesh3dSurfaceBlend; - d.depthWrite = mesh3dSurfaceDepthWrite; - d.doubleSided = mesh3dSurfaceDoubleSided; - d.shadowReceive = mesh3dShadowReceive; - d.alphaCutoff = mesh3dAlphaCutoff; + d.surfaceMode = mesh3dSurfaceMode; + d.surfaceBlend = mesh3dSurfaceBlend; + d.depthWrite = mesh3dSurfaceDepthWrite; + d.doubleSided = mesh3dSurfaceDoubleSided; + d.shadowReceive = mesh3dShadowReceive; + d.alphaCutoff = mesh3dAlphaCutoff; d.alphaTechnique = mesh3dAlphaTechnique; mesh3dDraws.push_back(d); } -void Graphics::setMesh3DNormalTexture(Texture *normal) { mesh3dNormalTexture = normal; } +void Graphics::setMesh3DNormalTexture(Texture* normal) { mesh3dNormalTexture = normal; } void Graphics::setMesh3DHeightTexture(Texture *height) { mesh3dHeightTexture = height; } -void Graphics::setMesh3DSceneDepth(Texture *depth) { - // Custom SPIR-V mesh shaders (incl. xray) are unsupported on the WebGPU - // backend (newMeshShaderFromSpv throws); store the handle for API parity so - // callers work unchanged and a future WGSL xray path can sample it. - mesh3dSceneDepthTexture = depth; -} +void Graphics::setMesh3DSceneDepth(Texture *depth) { mesh3dSceneDepthTexture = depth; } void Graphics::setMesh3DMaterial(float metallic, float roughness) { mesh3dMetallic = metallic; mesh3dRoughness = roughness; @@ -4763,7 +4766,7 @@ bool copyTextureToCpu(wgpu::Instance &instance, wgpu::Device &device, wgpu::Queu WGPUTexelCopyBufferInfo to{}; to.buffer = dst.Get(); to.layout.offset = 0; - to.layout.bytesPerRow = static_cast(bytesPerRow); + to.layout.bytesPerRow = static_cast(bytesPerRow); to.layout.rowsPerImage = static_cast(height); WGPUExtent3D extent{static_cast(width), static_cast(height), 1}; enc.CopyTextureToBuffer(reinterpret_cast(&from), @@ -4772,40 +4775,56 @@ bool copyTextureToCpu(wgpu::Instance &instance, wgpu::Device &device, wgpu::Queu wgpu::CommandBuffer cmd = enc.Finish(); queue.Submit(1, &cmd); - bool mapped = false; + struct MapState { + bool done = false; + bool success = false; + WGPUMapAsyncStatus status = WGPUMapAsyncStatus_Force32; + } map; WGPUBufferMapCallbackInfo cbInfo{}; - cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; - cbInfo.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*message*/, void *userdata1, - void * /*userdata2*/) { - bool *ok = static_cast(userdata1); - *ok = (status == WGPUMapAsyncStatus_Success); - }; - cbInfo.userdata1 = &mapped; - wgpuBufferMapAsync(dst.Get(), WGPUMapMode_Read, 0, size, cbInfo); - - int guard = 0; - while (!mapped && guard < 2000) { #if defined(__EMSCRIPTEN__) - emscripten_sleep(0); + cbInfo.mode = WGPUCallbackMode_AllowProcessEvents; +#else + cbInfo.mode = WGPUCallbackMode_WaitAnyOnly; #endif + cbInfo.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*message*/, void* userdata1, void* /*userdata2*/) { + auto* state = static_cast(userdata1); + state->status = status; + state->success = status == WGPUMapAsyncStatus_Success; + state->done = true; + }; + cbInfo.userdata1 = ↦ + WGPUFuture future = wgpuBufferMapAsync(dst.Get(), WGPUMapMode_Read, 0, size, cbInfo); + +#if defined(__EMSCRIPTEN__) + int guard = 0; + while (!map.done && guard < 2000) { + emscripten_sleep(0); wgpuInstanceProcessEvents(instance.Get()); ++guard; } - if (!mapped) return false; +#else + WGPUFutureWaitInfo waitInfo{}; + waitInfo.future = future; + (void)wgpuInstanceWaitAny(instance.Get(), 1, &waitInfo, UINT64_MAX); +#endif + if (!map.success) { + std::fprintf(stderr, "[webgpu] texture readback map failed: status=%d done=%d\n", int(map.status), + map.done ? 1 : 0); + return false; + } - const uint8_t *data = static_cast(dst.GetConstMappedRange(0, size)); + const uint8_t* data = static_cast(dst.GetConstMappedRange(0, size)); if (!data) return false; outRgba.resize(static_cast(width) * height * 4); for (int y = 0; y < height; ++y) - std::memcpy(outRgba.data() + size_t(y) * width * 4, data + size_t(y) * bytesPerRow, - size_t(width) * 4); + std::memcpy(outRgba.data() + size_t(y) * width * 4, data + size_t(y) * bytesPerRow, size_t(width) * 4); dst.Unmap(); return true; } } // namespace -Color Graphics::getPixelImpl(OffscreenCanvas *canvas, int x, int y) { +Color Graphics::getPixelImpl(OffscreenCanvas* canvas, int x, int y) { int w = canvas ? canvas->getWidth() : (sceneColorWidth > 0 ? sceneColorWidth : 1); int h = canvas ? canvas->getHeight() : (sceneColorHeight > 0 ? sceneColorHeight : 1); if (x < 0 || y < 0 || x >= w || y >= h) return Color(0.f, 0.f, 0.f, 0.f); diff --git a/test/procgen.cpp b/test/procgen.cpp index 4554bc8b..51bf4fe9 100644 --- a/test/procgen.cpp +++ b/test/procgen.cpp @@ -2268,9 +2268,77 @@ TEST_CASE("graphics.waterfall.paramsRoundTrip") { win->close(); } +TEST_CASE("graphics.waterfall.render.flowAndFoam") { + auto* win = eve::window::Window::create(); + auto* gfx = Graphics::create(); + REQUIRE(win != nullptr); + REQUIRE(gfx != nullptr); + eve::window::WindowSettings settings; + settings.width = 256; + settings.height = 256; + settings.centered = true; + REQUIRE(win->setWindowSettings(settings)); + + auto* camera = Camera3D::createCamera(); + camera->setEye(0.f, 0.f, 7.f); + camera->setTarget(0.f, 0.f, 0.f); + camera->setFov(50.f); + camera->setAmbient(0.2f, 0.22f, 0.25f); + + gfx->setBackgroundColor(Color(0.01f, 0.015f, 0.025f, 1.f)); + gfx->setScreenReadbackEnabled(true); + RenderSystem3D::setDirectionalLight(0.2f, 0.8f, 0.5f, 1.f, 0.95f, 0.85f); + + auto* present = Renderable2D::create(); + present->sprite()->width = 1.f; + present->sprite()->height = 1.f; + present->sprite()->a = 0.f; + + Waterfall* wf = gfx->newWaterfall(); + REQUIRE(wf != nullptr); + wf->createCurvedSheet(4.f, 6.f, 24, 32, 0.35f, 0.15f); + wf->setWaterColor(0.05f, 0.28f, 0.5f); + wf->setReflectionIntensity(0.65f); + wf->setSunIntensity(0.8f); + wf->setTurbulence(0.75f); + wf->setStreakCount(7); + + auto* waterfallEnt = Renderable3D::create(); + waterfallEnt->setMesh(wf->getMesh()); + waterfallEnt->setShader(wf->getShader()); + waterfallEnt->setReceiveShadow(false); + waterfallEnt->setCastShadow(false); + waterfallEnt->setCamera(camera); + + auto capture = [&](float time, float foam) { + wf->setTime(time); + wf->setFoamAmount(foam); + wf->bindParams(); + return waterCaptureLuma(gfx, 16); + }; + + const WaterLumaGrid t0 = capture(0.f, 0.8f); + const WaterLumaGrid t1 = capture(0.4f, 0.8f); + float rendered = 0.f; + for (float cell : t0.cells) rendered += cell; + rendered /= float(t0.cells.size()); + const float flowDiff = waterDiff(t0, t1); + + const WaterLumaGrid noFoam = capture(0.2f, 0.f); + const WaterLumaGrid foam = capture(0.2f, 1.1f); + const float foamDiff = waterDiff(noFoam, foam); + std::printf("waterfall render: rendered=%.2f flowDiff=%.2f foamDiff=%.2f\n", rendered, flowDiff, foamDiff); + REQUIRE(rendered > 1.f); + REQUIRE(flowDiff > 0.15f); + REQUIRE(foamDiff > 0.15f); + + delete wf; + win->close(); +} + TEST_CASE("graphics.water.paramsRoundTrip") { - auto *win = eve::window::Window::create(); - auto *gfx = Graphics::create(); + auto* win = eve::window::Window::create(); + auto* gfx = Graphics::create(); REQUIRE(win != nullptr); REQUIRE(gfx != nullptr); eve::window::WindowSettings settings; @@ -2383,15 +2451,15 @@ TEST_CASE("graphics.water.render.dynamicRipplesAndReflection") { }; // Dynamic ripples: different times give different patterns (and it renders). - const WaterLumaGrid t0 = captureWater(0.f); - const WaterLumaGrid t1 = captureWater(0.35f); - const float dynamic = waterDiff(t0, t1); - float rendered = 0.f; + const WaterLumaGrid t0 = captureWater(0.f); + const WaterLumaGrid t1 = captureWater(0.35f); + const float dynamic = waterDiff(t0, t1); + float rendered = 0.f; for (float c : t0.cells) rendered += c; rendered /= float(t0.cells.size()); std::printf("water render: dynamic=%.2f rendered=%.2f\n", dynamic, rendered); - CHECK(rendered > 1.f); // water surface is actually drawn - CHECK(dynamic > 0.3f); // ripples move over time + REQUIRE(rendered > 1.f); // water surface is actually drawn + REQUIRE(dynamic > 0.3f); // ripples move over time // Edge waves + middle drop ripples: flat (no ripples) differs from rippled. const WaterLumaGrid flat = [&] { @@ -2412,7 +2480,7 @@ TEST_CASE("graphics.water.render.dynamicRipplesAndReflection") { }(); const float rippleDiff = waterDiff(flat, wavy); std::printf("water render: rippleDiff=%.2f\n", rippleDiff); - CHECK(rippleDiff > 0.2f); // ripples (edge + middle) change the surface + REQUIRE(rippleDiff > 0.2f); // ripples (edge + middle) change the surface delete w; win->close();