Add end-to-end TextureFormatsTier1 tests for Snorm render attachments

Confirm Snorm texture formats function as render attachments with texture-formats-tier1 enabled. These tests validate correct fragment shader output conversion and storage to Snorm textures, matching readback values against expected ranges considering float-to-Snorm rules and a 0.6f ULP tolerance.

Bug: 421941589
Change-Id: Id31e4f89a1e5ad853567d9f99a7bf07779b6c466
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/246015
Reviewed-by: Corentin Wallez <cwallez@chromium.org>
Commit-Queue: Jiawei Shao <jiawei.shao@intel.com>
Reviewed-by: Jiawei Shao <jiawei.shao@intel.com>
diff --git a/src/dawn/native/d3d11/PhysicalDeviceD3D11.cpp b/src/dawn/native/d3d11/PhysicalDeviceD3D11.cpp
index 3f1b4c1..210dafb 100644
--- a/src/dawn/native/d3d11/PhysicalDeviceD3D11.cpp
+++ b/src/dawn/native/d3d11/PhysicalDeviceD3D11.cpp
@@ -161,6 +161,7 @@
     EnableFeature(Feature::DawnLoadResolveTexture);
     EnableFeature(Feature::DawnPartialLoadResolveTexture);
     EnableFeature(Feature::RG11B10UfloatRenderable);
+    EnableFeature(Feature::TextureFormatsTier1);
     if (mDeviceInfo.isUMA && mDeviceInfo.supportsMapNoOverwriteDynamicBuffers) {
         // With UMA we should allow mapping usages on more type of buffers.
         EnableFeature(Feature::BufferMapExtendedUsages);
diff --git a/src/dawn/native/d3d12/PhysicalDeviceD3D12.cpp b/src/dawn/native/d3d12/PhysicalDeviceD3D12.cpp
index 77a1668..e8a8e03 100644
--- a/src/dawn/native/d3d12/PhysicalDeviceD3D12.cpp
+++ b/src/dawn/native/d3d12/PhysicalDeviceD3D12.cpp
@@ -168,6 +168,7 @@
     EnableFeature(Feature::MultiDrawIndirect);
     EnableFeature(Feature::ClipDistances);
     EnableFeature(Feature::FlexibleTextureViews);
+    EnableFeature(Feature::TextureFormatsTier1);
 
     if (AreTimestampQueriesSupported()) {
         EnableFeature(Feature::TimestampQuery);
diff --git a/src/dawn/native/metal/PhysicalDeviceMTL.mm b/src/dawn/native/metal/PhysicalDeviceMTL.mm
index 7904642..1fa71ae 100644
--- a/src/dawn/native/metal/PhysicalDeviceMTL.mm
+++ b/src/dawn/native/metal/PhysicalDeviceMTL.mm
@@ -678,6 +678,7 @@
     EnableFeature(Feature::ClipDistances);
     EnableFeature(Feature::Float32Blendable);
     EnableFeature(Feature::FlexibleTextureViews);
+    EnableFeature(Feature::TextureFormatsTier1);
 
     // The function subgroupBroadcast(f16) fails for some edge cases on intel gen-9 devices.
     // See crbug.com/391680973
diff --git a/src/dawn/tests/BUILD.gn b/src/dawn/tests/BUILD.gn
index efd1809..78dab01 100644
--- a/src/dawn/tests/BUILD.gn
+++ b/src/dawn/tests/BUILD.gn
@@ -684,6 +684,7 @@
     "end2end/Texture3DTests.cpp",
     "end2end/TextureCorruptionTests.cpp",
     "end2end/TextureFormatTests.cpp",
+    "end2end/TextureFormatsTier1Tests.cpp",
     "end2end/TextureShaderBuiltinTests.cpp",
     "end2end/TextureSubresourceTests.cpp",
     "end2end/TextureViewTests.cpp",
diff --git a/src/dawn/tests/DawnTest.cpp b/src/dawn/tests/DawnTest.cpp
index e12fe30..be1452b 100644
--- a/src/dawn/tests/DawnTest.cpp
+++ b/src/dawn/tests/DawnTest.cpp
@@ -2125,5 +2125,26 @@
 }
 
 template class ExpectBetweenColors<utils::RGBA8>;
+
+template <typename T>
+testing::AssertionResult ExpectBetweenSnormTextureBounds<T>::Check(const void* data, size_t size) {
+    DAWN_ASSERT(size == sizeof(T) * expectedLower.size());
+    DAWN_ASSERT(expectedLower.size() == expectedUpper.size());
+
+    const T* actual = static_cast<const T*>(data);
+
+    for (size_t i = 0; i < expectedLower.size(); ++i) {
+        if (!(actual[i] >= expectedLower[i] && actual[i] <= expectedUpper[i])) {
+            return testing::AssertionFailure()
+                   << absl::StrFormat("Expected data[%d] to be between %d and %d, actual %d\n", i,
+                                      expectedLower[i], expectedUpper[i], actual[i]);
+        }
+    }
+
+    return testing::AssertionSuccess();
+}
+
+template class ExpectBetweenSnormTextureBounds<int8_t>;
+
 }  // namespace detail
 }  // namespace dawn
diff --git a/src/dawn/tests/DawnTest.h b/src/dawn/tests/DawnTest.h
index c6d171d..87a8d8e 100644
--- a/src/dawn/tests/DawnTest.h
+++ b/src/dawn/tests/DawnTest.h
@@ -125,6 +125,9 @@
 #define EXPECT_TEXTURE_FLOAT16_EQ(...) \
     AddTextureExpectation<float, uint16_t>(__FILE__, __LINE__, __VA_ARGS__)
 
+#define EXPECT_TEXTURE_SNORM_BETWEEN(...) \
+    AddSnormTextureBoundsExpectation<int8_t>(__FILE__, __LINE__, __VA_ARGS__)
+
 // Matcher for C++ types to verify that their internal C-handles are identical.
 MATCHER_P(CHandleIs, cType, "") {
     return arg.Get() == cType;
@@ -170,6 +173,8 @@
 class ExpectEq;
 template <typename T>
 class ExpectBetweenColors;
+template <typename T>
+class ExpectBetweenSnormTextureBounds;
 }  // namespace detail
 
 namespace wire {
@@ -548,6 +553,46 @@
             texture, {x, y}, {1, 1}, level, aspect, sizeof(T), bytesPerRow);
     }
 
+    template <typename T>
+    std::ostringstream& AddSnormTextureBoundsExpectation(
+        const char* file,
+        int line,
+        const std::vector<T>& expectedL,
+        const std::vector<T>& expectedU,
+        const wgpu::Texture& texture,
+        wgpu::Origin3D origin,
+        wgpu::Extent3D extent,
+        wgpu::TextureFormat format,
+        uint32_t level = 0,
+        wgpu::TextureAspect aspect = wgpu::TextureAspect::All,
+        uint32_t bytesPerRow = 0) {
+        // No device passed explicitly. Default it, and forward the rest of the args.
+        return AddSnormTextureBoundsExpectation(file, line, this->device, expectedL, expectedU,
+                                                texture, origin, extent, format, level, aspect,
+                                                bytesPerRow);
+    }
+
+    template <typename T>
+    std::ostringstream& AddSnormTextureBoundsExpectation(
+        const char* file,
+        int line,
+        const wgpu::Device& targetDevice,
+        const std::vector<T>& expectedL,
+        const std::vector<T>& expectedU,
+        const wgpu::Texture& texture,
+        wgpu::Origin3D origin,
+        wgpu::Extent3D extent,
+        wgpu::TextureFormat format,
+        uint32_t level = 0,
+        wgpu::TextureAspect aspect = wgpu::TextureAspect::All,
+        uint32_t bytesPerRow = 0) {
+        uint32_t texelBlockSize = utils::GetTexelBlockSizeInBytes(format);
+        return AddTextureExpectationImpl(
+            file, line, std::move(targetDevice),
+            new detail::ExpectBetweenSnormTextureBounds<T>(expectedL, expectedU), texture, origin,
+            extent, level, aspect, texelBlockSize, bytesPerRow);
+    }
+
     std::ostringstream& ExpectSampledFloatData(wgpu::Texture texture,
                                                uint32_t width,
                                                uint32_t height,
@@ -910,6 +955,21 @@
 // lerp(color0, color1, t) where t is [0,1]. But I don't want to be too strict here.
 extern template class ExpectBetweenColors<utils::RGBA8>;
 
+template <typename T>
+class ExpectBetweenSnormTextureBounds : public Expectation {
+  public:
+    // Inclusive for now
+    ExpectBetweenSnormTextureBounds(const std::vector<T>& expectedL,
+                                    const std::vector<T>& expectedU)
+        : expectedLower(expectedL), expectedUpper(expectedU) {}
+    testing::AssertionResult Check(const void* data, size_t size) override;
+
+  private:
+    std::vector<T> expectedLower;
+    std::vector<T> expectedUpper;
+};
+extern template class ExpectBetweenSnormTextureBounds<int8_t>;
+
 class CustomTextureExpectation : public Expectation {
   public:
     ~CustomTextureExpectation() override = default;
diff --git a/src/dawn/tests/end2end/TextureFormatsTier1Tests.cpp b/src/dawn/tests/end2end/TextureFormatsTier1Tests.cpp
new file mode 100644
index 0000000..acbb9eb
--- /dev/null
+++ b/src/dawn/tests/end2end/TextureFormatsTier1Tests.cpp
@@ -0,0 +1,179 @@
+// Copyright 2025 The Dawn & Tint Authors
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// 3. Neither the name of the copyright holder nor the names of its
+//    contributors may be used to endorse or promote products derived from
+//    this software without specific prior written permission.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+#include <cmath>
+#include <vector>
+
+#include "dawn/tests/DawnTest.h"
+#include "dawn/utils/ComboRenderPipelineDescriptor.h"
+#include "dawn/utils/WGPUHelpers.h"
+
+namespace dawn {
+namespace {
+
+class RenderAttachmentSnormFormatsTest : public DawnTest {
+  protected:
+    std::vector<wgpu::FeatureName> GetRequiredFeatures() override {
+        std::vector<wgpu::FeatureName> requiredFeatures = {};
+        if (SupportsFeatures({wgpu::FeatureName::TextureFormatsTier1})) {
+            requiredFeatures.push_back(wgpu::FeatureName::TextureFormatsTier1);
+        }
+        return requiredFeatures;
+    }
+
+    const char* GetSinglePointVS() {
+        return R"(
+            @vertex
+            fn main() -> @builtin(position) vec4<f32> {
+                return vec4<f32>(0.0, 0.0, 0.0, 1.0);
+            }
+        )";
+    }
+
+    int8_t ConvertFloatToSnorm8(float value) {
+        float roundedValue = (value >= 0) ? (value + 0.5f) : (value - 0.5f);
+        float clampedValue = std::clamp(roundedValue, -128.0f, 127.0f);
+        return static_cast<int8_t>(clampedValue);
+    }
+
+    void RunSingleFormatTest(wgpu::TextureFormat format,
+                             const char* fsCode,
+                             const std::vector<float>& originData) {
+        wgpu::Extent3D textureSize = {1, 1, 1};
+        wgpu::TextureDescriptor textureDesc;
+        textureDesc.usage = wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::CopySrc;
+        textureDesc.dimension = wgpu::TextureDimension::e2D;
+        textureDesc.size = textureSize;
+        textureDesc.format = format;
+
+        wgpu::Texture texture = device.CreateTexture(&textureDesc);
+
+        wgpu::ShaderModule vsModule = utils::CreateShaderModule(device, GetSinglePointVS());
+        wgpu::ShaderModule fsModule = utils::CreateShaderModule(device, fsCode);
+
+        utils::ComboRenderPipelineDescriptor pipelineDesc;
+        pipelineDesc.vertex.module = vsModule;
+        pipelineDesc.cFragment.module = fsModule;
+        pipelineDesc.primitive.topology = wgpu::PrimitiveTopology::PointList;
+        pipelineDesc.cFragment.targetCount = 1;
+        pipelineDesc.cTargets[0].format = format;
+        wgpu::RenderPipeline pipeline = device.CreateRenderPipeline(&pipelineDesc);
+
+        wgpu::TextureView textureView = texture.CreateView();
+
+        utils::ComboRenderPassDescriptor renderPass;
+        renderPass.cColorAttachments[0].view = textureView;
+        renderPass.cColorAttachments[0].loadOp = wgpu::LoadOp::Clear;
+        renderPass.cColorAttachments[0].storeOp = wgpu::StoreOp::Store;
+        renderPass.cColorAttachments[0].clearValue = {0.0f, 0.0f, 0.0f, 0.0f};
+        renderPass.colorAttachmentCount = 1;
+
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&renderPass);
+
+        pass.SetPipeline(pipeline);
+        pass.Draw(1);
+        pass.End();
+
+        wgpu::CommandBuffer commands = encoder.Finish();
+        queue.Submit(1, &commands);
+
+        std::vector<int8_t> expectedLowerBounds;
+        std::vector<int8_t> expectedUpperBounds;
+
+        for (uint32_t i = 0; i < originData.size(); ++i) {
+            float floatComponent = originData[i];
+            float scaledComponent = floatComponent * 127.f;
+
+            int8_t lowerSnormExpectation = ConvertFloatToSnorm8(scaledComponent - 0.6f);
+            int8_t upperSnormExpectation = ConvertFloatToSnorm8(scaledComponent + 0.6f);
+
+            expectedLowerBounds.push_back(lowerSnormExpectation);
+            expectedUpperBounds.push_back(upperSnormExpectation);
+        }
+
+        EXPECT_TEXTURE_SNORM_BETWEEN(expectedLowerBounds, expectedUpperBounds, texture, {0, 0},
+                                     {1, 1}, format);
+    }
+};
+
+// Test that r8snorm format is valid as renderable texture if
+// 'texture-formats-tier1' is enabled.
+TEST_P(RenderAttachmentSnormFormatsTest, R8SnormRenderAttachment) {
+    DAWN_TEST_UNSUPPORTED_IF(!device.HasFeature(wgpu::FeatureName::TextureFormatsTier1));
+
+    const char* fs_r8snorm = R"(
+        @fragment
+        fn main() -> @location(0) vec4<f32> {
+            return vec4<f32>(-0.5, 0.0, 0.0, 1.0);
+        }
+    )";
+    std::vector<float> originData = {-0.5};
+
+    RunSingleFormatTest(wgpu::TextureFormat::R8Snorm, fs_r8snorm, originData);
+}
+
+// Test that rg8snorm format is valid as renderable texture if
+// 'texture-formats-tier1' is enabled.
+TEST_P(RenderAttachmentSnormFormatsTest, RG8SnormRenderAttachment) {
+    DAWN_TEST_UNSUPPORTED_IF(!device.HasFeature(wgpu::FeatureName::TextureFormatsTier1));
+
+    const char* fs_rg8snorm = R"(
+        @fragment
+        fn main() -> @location(0) vec4<f32> {
+            return vec4<f32>(-0.5, 0.25, 0.0, 1.0);
+        }
+    )";
+    std::vector<float> originData = {-0.5, 0.25};
+
+    RunSingleFormatTest(wgpu::TextureFormat::RG8Snorm, fs_rg8snorm, originData);
+}
+
+// Test that r8snorm format is valid as renderable texture if
+// 'texture-formats-tier1' is enabled.
+TEST_P(RenderAttachmentSnormFormatsTest, RGBA8SnormRenderAttachment) {
+    DAWN_TEST_UNSUPPORTED_IF(!device.HasFeature(wgpu::FeatureName::TextureFormatsTier1));
+
+    const char* fs_rgba8snorm = R"(
+        @fragment
+        fn main() -> @location(0) vec4<f32> {
+            return vec4<f32>(-0.5, 0.25, -1.0, 1.0);
+        }
+    )";
+    std::vector<float> originData = {-0.5, 0.25, -1.0, 1.0};
+    RunSingleFormatTest(wgpu::TextureFormat::RGBA8Snorm, fs_rgba8snorm, originData);
+}
+
+DAWN_INSTANTIATE_TEST(RenderAttachmentSnormFormatsTest,
+                      D3D11Backend(),
+                      D3D12Backend(),
+                      MetalBackend(),
+                      VulkanBackend(),
+                      OpenGLBackend());
+
+}  // anonymous namespace
+}  // namespace dawn