Tint: Add support for snorm10-10-10-2 vertex format

Implements the snorm10-10-10-2 vertex format decoding in the HLSL
pipeline and adds support to the vertex pulling transform with
corresponding compiler unit tests.

Test: tint_unittests --gtest_filter="*HlslWriterDecomposeSnorm10_10_10_2Test.*"
Test: tint_unittests --gtest_filter="*MslWriter_VertexPullingTest.*"
Bug: b/535283909
Change-Id: Ibfd9e0e3899e60a410c20bcd4516ef1eb6319387
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/324835
Commit-Queue: Ran Wang <wangra@google.com>
Reviewed-by: dan sinclair <dsinclair@chromium.org>
diff --git a/src/tint/api/common/vertex_pulling_config.h b/src/tint/api/common/vertex_pulling_config.h
index 418975a..407b02f 100644
--- a/src/tint/api/common/vertex_pulling_config.h
+++ b/src/tint/api/common/vertex_pulling_config.h
@@ -78,6 +78,7 @@
     kSint32x4,         // sint32x4
     kUnorm10_10_10_2,  // unorm10-10-10-2
     kUnorm8x4BGRA,     // unorm8x4-bgra
+    kSnorm10_10_10_2,  // snorm10-10-10-2
 };
 
 /// @param out the stream to write to
@@ -210,6 +211,9 @@
         case VertexFormat::kUnorm8x4BGRA:
             out << "unorm8x4BGRA";
             break;
+        case VertexFormat::kSnorm10_10_10_2:
+            out << "snorm10_10_10_2";
+            break;
     }
     return out;
 }
@@ -292,7 +296,7 @@
 };
 
 /// Reflection for VertexFormat.
-TINT_REFLECT_ENUM_RANGE(tint::VertexFormat, kUint8, kUnorm8x4BGRA);
+TINT_REFLECT_ENUM_RANGE(tint::VertexFormat, kUint8, kSnorm10_10_10_2);
 
 /// Reflection for VertexStepMode.
 TINT_REFLECT_ENUM_RANGE(tint::VertexStepMode, kVertex, kInstance);
diff --git a/src/tint/lang/core/ir/transform/vertex_pulling.cc b/src/tint/lang/core/ir/transform/vertex_pulling.cc
index aad27e9..1d59029 100644
--- a/src/tint/lang/core/ir/transform/vertex_pulling.cc
+++ b/src/tint/lang/core/ir/transform/vertex_pulling.cc
@@ -85,6 +85,7 @@
         case VertexFormat::kUint32x4:
         case VertexFormat::kSint32x4:
         case VertexFormat::kUnorm10_10_10_2:
+        case VertexFormat::kSnorm10_10_10_2:
         case VertexFormat::kUnorm8x4BGRA:
             return 4;
     }
@@ -678,6 +679,17 @@
                 auto* div = b.Composite<vec4f>(1023_f, 1023_f, 1023_f, 3_f);
                 return float_value(b.Divide(b.Convert<vec4f>(mask), div)->Result());
             }
+            case VertexFormat::kSnorm10_10_10_2: {
+                auto* s32 = b.Bitcast<i32>(load_u32(0));
+                auto* s32s = b.Construct<vec4i>(s32);
+                auto* shl = b.ShiftLeft(s32s, b.Composite<vec4u>(22_u, 12_u, 2_u, 0_u));
+                auto* shr = b.ShiftRight(shl, b.Composite<vec4u>(22_u, 22_u, 22_u, 30_u));
+                auto* div = b.Composite<vec4f>(511_f, 511_f, 511_f, 1_f);
+                auto* normalized = b.Divide(b.Convert<vec4f>(shr), div);
+                auto* clamped =
+                    b.Call<vec4f>(core::BuiltinFn::kMax, normalized, b.Splat<vec4f>(-1_f));
+                return float_value(clamped->Result());
+            }
         }
         TINT_IR_UNREACHABLE(ir);
     }
diff --git a/src/tint/lang/core/ir/transform/vertex_pulling_test.cc b/src/tint/lang/core/ir/transform/vertex_pulling_test.cc
index 35dd0f1..05d8eff 100644
--- a/src/tint/lang/core/ir/transform/vertex_pulling_test.cc
+++ b/src/tint/lang/core/ir/transform/vertex_pulling_test.cc
@@ -3160,5 +3160,97 @@
     EXPECT_EQ(expect, str());
 }
 
+TEST_F(MslWriter_VertexPullingTest, OneAttribute_Snorm10_10_10_2) {
+    auto* ep = b.Function("foo", ty.vec4f(), core::ir::Function::PipelineStage::kVertex);
+    auto* param = b.FunctionParam("input", ty.vec4f());
+    param->SetLocation(0);
+    ep->AppendParam(param);
+    ep->SetReturnBuiltin(core::BuiltinValue::kPosition);
+    b.Append(ep->Block(), [&] {  //
+        b.Return(ep, param);
+    });
+
+    auto* src = R"(
+%foo = @vertex func(%input:vec4<f32> [@location(0)]):vec4<f32> [@position] {
+  $B1: {
+    ret %input
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    VertexPullingConfig cfg;
+    cfg.pulling_group = 4u;
+    cfg.vertex_state = {{{4, VertexStepMode::kVertex, {{VertexFormat::kSnorm10_10_10_2, 0, 0}}}}};
+    Run(VertexPulling, cfg);
+
+    auto* expect = R"(
+$B1: {  # root
+  %tint_vertex_buffer_0:ptr<storage, array<u32>, read> = var undef @binding_point(4, 0)
+}
+
+%foo = @vertex func(%tint_vertex_index:u32 [@vertex_index]):vec4<f32> [@position] {
+  $B2: {
+    %4:ptr<storage, u32, read> = access %tint_vertex_buffer_0, %tint_vertex_index
+    %5:u32 = load %4
+    %6:i32 = bitcast<i32> %5
+    %7:vec4<i32> = construct %6
+    %8:vec4<i32> = shl %7, vec4<u32>(22u, 12u, 2u, 0u)
+    %9:vec4<i32> = shr %8, vec4<u32>(22u, 22u, 22u, 30u)
+    %10:vec4<f32> = convert %9
+    %11:vec4<f32> = div %10, vec4<f32>(511.0f, 511.0f, 511.0f, 1.0f)
+    %12:vec4<f32> = max %11, vec4<f32>(-1.0f)
+    ret %12
+  }
+}
+)";
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(MslWriter_VertexPullingTest, OneAttribute_Unorm10_10_10_2) {
+    auto* ep = b.Function("foo", ty.vec4f(), core::ir::Function::PipelineStage::kVertex);
+    auto* param = b.FunctionParam("input", ty.vec4f());
+    param->SetLocation(0);
+    ep->AppendParam(param);
+    ep->SetReturnBuiltin(core::BuiltinValue::kPosition);
+    b.Append(ep->Block(), [&] {  //
+        b.Return(ep, param);
+    });
+
+    auto* src = R"(
+%foo = @vertex func(%input:vec4<f32> [@location(0)]):vec4<f32> [@position] {
+  $B1: {
+    ret %input
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    VertexPullingConfig cfg;
+    cfg.pulling_group = 4u;
+    cfg.vertex_state = {{{4, VertexStepMode::kVertex, {{VertexFormat::kUnorm10_10_10_2, 0, 0}}}}};
+    Run(VertexPulling, cfg);
+
+    auto* expect = R"(
+$B1: {  # root
+  %tint_vertex_buffer_0:ptr<storage, array<u32>, read> = var undef @binding_point(4, 0)
+}
+
+%foo = @vertex func(%tint_vertex_index:u32 [@vertex_index]):vec4<f32> [@position] {
+  $B2: {
+    %4:ptr<storage, u32, read> = access %tint_vertex_buffer_0, %tint_vertex_index
+    %5:u32 = load %4
+    %6:vec4<u32> = construct %5
+    %7:vec4<u32> = shr %6, vec4<u32>(0u, 10u, 20u, 30u)
+    %8:vec4<u32> = and %7, vec4<u32>(1023u, 1023u, 1023u, 3u)
+    %9:vec4<f32> = convert %8
+    %10:vec4<f32> = div %9, vec4<f32>(1023.0f, 1023.0f, 1023.0f, 3.0f)
+    ret %10
+  }
+}
+)";
+    EXPECT_EQ(expect, str());
+}
+
 }  // namespace
 }  // namespace tint::core::ir::transform
diff --git a/src/tint/lang/hlsl/writer/common/options.h b/src/tint/lang/hlsl/writer/common/options.h
index c829c97..a7b5661 100644
--- a/src/tint/lang/hlsl/writer/common/options.h
+++ b/src/tint/lang/hlsl/writer/common/options.h
@@ -262,6 +262,9 @@
     /// The binding points that will be ignored by the rebustness transform.
     std::vector<BindingPoint> ignored_by_robustness_transform;
 
+    /// Vertex shader locations that use the snorm10-10-10-2 format (which is emulated on D3D).
+    std::vector<uint32_t> snorm10_10_10_2_locations = {};
+
     /// Pixel local configuration
     PixelLocalOptions pixel_local;
 
@@ -294,6 +297,7 @@
                  num_workgroups_start_offset,
                  bindings,
                  ignored_by_robustness_transform,
+                 snorm10_10_10_2_locations,
                  pixel_local,
                  resource_table,
                  substitute_overrides_config);
diff --git a/src/tint/lang/hlsl/writer/raise/BUILD.bazel b/src/tint/lang/hlsl/writer/raise/BUILD.bazel
index a1b3083..67c18d7 100644
--- a/src/tint/lang/hlsl/writer/raise/BUILD.bazel
+++ b/src/tint/lang/hlsl/writer/raise/BUILD.bazel
@@ -47,6 +47,7 @@
     "array_offset_from_uniform.cc",
     "binary_polyfill.cc",
     "builtin_polyfill.cc",
+    "decompose_snorm10_10_10_2.cc",
     "decompose_storage_access.cc",
     "extract_ternary_values.cc",
     "localize_struct_array_assignment.cc",
@@ -64,6 +65,7 @@
     "array_offset_from_uniform.h",
     "binary_polyfill.h",
     "builtin_polyfill.h",
+    "decompose_snorm10_10_10_2.h",
     "decompose_storage_access.h",
     "extract_ternary_values.h",
     "localize_struct_array_assignment.h",
@@ -113,6 +115,7 @@
     "array_offset_from_uniform_test.cc",
     "binary_polyfill_test.cc",
     "builtin_polyfill_test.cc",
+    "decompose_snorm10_10_10_2_test.cc",
     "decompose_storage_access_test.cc",
     "extract_ternary_values_test.cc",
     "localize_struct_array_assignment_test.cc",
diff --git a/src/tint/lang/hlsl/writer/raise/BUILD.cmake b/src/tint/lang/hlsl/writer/raise/BUILD.cmake
index 782f314..1fad0be 100644
--- a/src/tint/lang/hlsl/writer/raise/BUILD.cmake
+++ b/src/tint/lang/hlsl/writer/raise/BUILD.cmake
@@ -47,6 +47,8 @@
   lang/hlsl/writer/raise/binary_polyfill.h
   lang/hlsl/writer/raise/builtin_polyfill.cc
   lang/hlsl/writer/raise/builtin_polyfill.h
+  lang/hlsl/writer/raise/decompose_snorm10_10_10_2.cc
+  lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h
   lang/hlsl/writer/raise/decompose_storage_access.cc
   lang/hlsl/writer/raise/decompose_storage_access.h
   lang/hlsl/writer/raise/extract_ternary_values.cc
@@ -110,6 +112,7 @@
   lang/hlsl/writer/raise/array_offset_from_uniform_test.cc
   lang/hlsl/writer/raise/binary_polyfill_test.cc
   lang/hlsl/writer/raise/builtin_polyfill_test.cc
+  lang/hlsl/writer/raise/decompose_snorm10_10_10_2_test.cc
   lang/hlsl/writer/raise/decompose_storage_access_test.cc
   lang/hlsl/writer/raise/extract_ternary_values_test.cc
   lang/hlsl/writer/raise/localize_struct_array_assignment_test.cc
diff --git a/src/tint/lang/hlsl/writer/raise/BUILD.gn b/src/tint/lang/hlsl/writer/raise/BUILD.gn
index 8b62428..6aa7006 100644
--- a/src/tint/lang/hlsl/writer/raise/BUILD.gn
+++ b/src/tint/lang/hlsl/writer/raise/BUILD.gn
@@ -53,6 +53,8 @@
     "binary_polyfill.h",
     "builtin_polyfill.cc",
     "builtin_polyfill.h",
+    "decompose_snorm10_10_10_2.cc",
+    "decompose_snorm10_10_10_2.h",
     "decompose_storage_access.cc",
     "decompose_storage_access.h",
     "extract_ternary_values.cc",
@@ -110,6 +112,7 @@
       "array_offset_from_uniform_test.cc",
       "binary_polyfill_test.cc",
       "builtin_polyfill_test.cc",
+      "decompose_snorm10_10_10_2_test.cc",
       "decompose_storage_access_test.cc",
       "extract_ternary_values_test.cc",
       "localize_struct_array_assignment_test.cc",
diff --git a/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.cc b/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.cc
new file mode 100644
index 0000000..8ee6c8c
--- /dev/null
+++ b/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.cc
@@ -0,0 +1,152 @@
+// Copyright 2026 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 "src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h"
+
+#include <algorithm>
+#include <unordered_set>
+#include <utility>
+
+#include "src/tint/lang/core/ir/builder.h"
+#include "src/tint/lang/core/ir/module.h"
+#include "src/tint/lang/core/ir/validator.h"
+#include "src/tint/lang/core/type/struct.h"
+#include "src/tint/utils/ice/ice.h"
+#include "src/tint/utils/rtti/switch.h"
+
+using namespace tint::core::fluent_types;     // NOLINT
+using namespace tint::core::number_suffixes;  // NOLINT
+
+namespace tint::hlsl::writer::raise {
+namespace {
+
+struct State {
+    core::ir::Module& ir;
+    const std::vector<uint32_t>& locations;
+    core::ir::Builder b{ir};
+    core::type::Manager& ty{ir.Types()};
+
+    struct DecodeResult {
+        core::ir::InstructionResult* value = nullptr;
+        core::ir::Instruction* multiply_inst = nullptr;
+    };
+
+    void Process() {
+        std::unordered_set<uint32_t> emulated_locs(locations.begin(), locations.end());
+
+        for (auto* func : ir.functions) {
+            if (!func->IsVertex()) {
+                continue;
+            }
+
+            for (auto* param : func->Params()) {
+                auto* struct_ty = param->Type()->As<core::type::Struct>();
+                // Vertex shader parameters are always structs after ShaderIO
+                TINT_ASSERT(struct_ty);
+
+                for (auto* member : struct_ty->Members()) {
+                    auto member_loc = member->Attributes().location;
+                    if (!member_loc.has_value() || !emulated_locs.contains(member_loc.value())) {
+                        continue;
+                    }
+                    uint32_t member_index = member->Index();
+
+                    param->ForEachUseUnsorted([&](core::ir::Usage u) {
+                        auto* access = u.instruction->As<core::ir::Access>();
+                        if (!access || access->Indices().size() != 1) {
+                            return;
+                        }
+                        auto* const_idx = access->Indices()[0]->As<core::ir::Constant>();
+                        if (!const_idx || const_idx->Value()->ValueAs<uint32_t>() != member_index) {
+                            return;
+                        }
+
+                        // Decode the access locally close to its usage to minimize register
+                        // liveness/pressure. Duplicate access decodes will be merged later by
+                        // Common Subexpression Elimination (CSE) and instruction deduplication
+                        // at the downstream native shader compilers
+                        auto* access_val = access->Result();
+                        b.InsertAfter(access, [&] {
+                            auto decoded = Decode(access_val);
+
+                            // Replace all uses of the access result with the decoded result, except
+                            // the use in the decoding logic's first instruction. We manually
+                            // iterate a copy of the usages rather than using
+                            // Value::ReplaceAllUsesWith(replacer) because the latter expects every
+                            // usage to be replaced with a different value, and would infinite loop
+                            // if we returned the original value for the decoder's first
+                            // instruction.
+                            auto usages = access_val->UsagesSorted();
+                            for (auto& use : usages) {
+                                if (use.instruction != decoded.multiply_inst) {
+                                    use.instruction->SetOperand(use.operand_index, decoded.value);
+                                }
+                            }
+                        });
+                    });
+                }
+            }
+        }
+    }
+
+    DecodeResult Decode(core::ir::Value* input) {
+        // Reconstruct integer values:
+        auto* scaled = b.Multiply(input, b.Composite<vec4f>(1023_f, 1023_f, 1023_f, 3_f));
+        auto* rounded = b.Call<vec4f>(core::BuiltinFn::kRound, scaled);
+
+        // Sign-extend:
+        // The shift left values (22, 22, 22, 30) here differ from vertex pulling's
+        // (22, 12, 2, 0) because the input components have already been unpacked into separate
+        // vector lanes by the hardware fetcher before reaching the shader. We only need to
+        // sign-extend each lane's value (10 bits for XYZ, 2 bits for W) in-place.
+        auto* s32s = b.Convert<vec4i>(rounded);
+        auto* shl = b.ShiftLeft(s32s, b.Composite<vec4u>(22_u, 22_u, 22_u, 30_u));
+        auto* shr = b.ShiftRight(shl, b.Composite<vec4u>(22_u, 22_u, 22_u, 30_u));
+
+        // Normalize:
+        auto* normalized =
+            b.Divide(b.Convert<vec4f>(shr), b.Composite<vec4f>(511_f, 511_f, 511_f, 1_f));
+        auto* decoded = b.Call<vec4f>(core::BuiltinFn::kMax, normalized, b.Splat<vec4f>(-1_f));
+
+        return {decoded->Result(), scaled};
+    }
+};
+
+}  // namespace
+
+Result<SuccessType> DecomposeSnorm10_10_10_2(core::ir::Module& ir,
+                                             const std::vector<uint32_t>& locations) {
+    AssertValid(ir, "before hlsl.DecomposeSnorm10_10_10_2");
+
+    if (!locations.empty()) {
+        State{ir, locations}.Process();
+    }
+
+    return Success;
+}
+
+}  // namespace tint::hlsl::writer::raise
diff --git a/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h b/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h
new file mode 100644
index 0000000..07f8789
--- /dev/null
+++ b/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h
@@ -0,0 +1,51 @@
+// Copyright 2026 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.
+
+#ifndef SRC_TINT_LANG_HLSL_WRITER_RAISE_DECOMPOSE_SNORM10_10_10_2_H_
+#define SRC_TINT_LANG_HLSL_WRITER_RAISE_DECOMPOSE_SNORM10_10_10_2_H_
+
+#include <vector>
+
+#include "src/tint/utils/result.h"
+
+// Forward declarations.
+namespace tint::core::ir {
+class Module;
+}  // namespace tint::core::ir
+
+namespace tint::hlsl::writer::raise {
+
+/// DecomposeSnorm10_10_10_2 is a transform that polyfills snorm10-10-10-2 vertex inputs.
+/// @param module the module to transform
+/// @param locations the locations to apply the polyfill
+/// @returns success or failure
+Result<SuccessType> DecomposeSnorm10_10_10_2(core::ir::Module& module,
+                                             const std::vector<uint32_t>& locations);
+
+}  // namespace tint::hlsl::writer::raise
+
+#endif  // SRC_TINT_LANG_HLSL_WRITER_RAISE_DECOMPOSE_SNORM10_10_10_2_H_
diff --git a/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2_test.cc b/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2_test.cc
new file mode 100644
index 0000000..9c93bcc
--- /dev/null
+++ b/src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2_test.cc
@@ -0,0 +1,207 @@
+// Copyright 2026 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 "src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h"
+
+#include <gtest/gtest.h>
+
+#include "src/tint/lang/core/fluent_types.h"
+#include "src/tint/lang/core/ir/function.h"
+#include "src/tint/lang/core/ir/transform/helper_test.h"
+#include "src/tint/lang/core/number.h"
+
+using namespace tint::core::fluent_types;     // NOLINT
+using namespace tint::core::number_suffixes;  // NOLINT
+
+namespace tint::hlsl::writer::raise {
+namespace {
+
+class HlslWriterDecomposeSnorm10_10_10_2Test : public core::ir::transform::TransformTest {
+  protected:
+    core::IOAttributes Location(uint32_t loc) {
+        core::IOAttributes attrs;
+        attrs.location = loc;
+        return attrs;
+    }
+};
+
+TEST_F(HlslWriterDecomposeSnorm10_10_10_2Test, NoLocations) {
+    auto* inputs =
+        ty.Struct(mod.symbols.New("Inputs"), {
+                                                 {mod.symbols.New("pos"), ty.vec4f(), Location(0u)},
+                                             });
+
+    auto* func = b.Function("foo", ty.vec4f(), core::ir::Function::PipelineStage::kVertex);
+    func->SetReturnBuiltin(core::BuiltinValue::kPosition);
+    auto* param = b.FunctionParam("input", inputs);
+    func->AppendParam(param);
+
+    b.Append(func->Block(), [&] {
+        auto* pos_val = b.Access(ty.vec4f(), param, 0_u);
+        b.Return(func, pos_val);
+    });
+
+    auto* src = R"(
+Inputs = struct @align(16) {
+  pos:vec4<f32> @offset(0), @location(0)
+}
+
+%foo = @vertex func(%input:Inputs):vec4<f32> [@position] {
+  $B1: {
+    %3:vec4<f32> = access %input, 0u
+    ret %3
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = src;
+    Run(DecomposeSnorm10_10_10_2, std::vector<uint32_t>{});
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(HlslWriterDecomposeSnorm10_10_10_2Test, DecomposeLocation0) {
+    auto* inputs = ty.Struct(mod.symbols.New("Inputs"),
+                             {
+                                 {mod.symbols.New("pos"), ty.vec4f(), Location(0u)},
+                                 {mod.symbols.New("norm"), ty.vec4f(), Location(1u)},
+                             });
+
+    auto* func = b.Function("foo", ty.vec4f(), core::ir::Function::PipelineStage::kVertex);
+    func->SetReturnBuiltin(core::BuiltinValue::kPosition);
+    auto* param = b.FunctionParam("input", inputs);
+    func->AppendParam(param);
+
+    b.Append(func->Block(), [&] {
+        auto* pos_val = b.Access(ty.vec4f(), param, 0_u);
+        b.Return(func, pos_val);
+    });
+
+    auto* src = R"(
+Inputs = struct @align(16) {
+  pos:vec4<f32> @offset(0), @location(0)
+  norm:vec4<f32> @offset(16), @location(1)
+}
+
+%foo = @vertex func(%input:Inputs):vec4<f32> [@position] {
+  $B1: {
+    %3:vec4<f32> = access %input, 0u
+    ret %3
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Inputs = struct @align(16) {
+  pos:vec4<f32> @offset(0), @location(0)
+  norm:vec4<f32> @offset(16), @location(1)
+}
+
+%foo = @vertex func(%input:Inputs):vec4<f32> [@position] {
+  $B1: {
+    %3:vec4<f32> = access %input, 0u
+    %4:vec4<f32> = mul %3, vec4<f32>(1023.0f, 1023.0f, 1023.0f, 3.0f)
+    %5:vec4<f32> = round %4
+    %6:vec4<i32> = convert %5
+    %7:vec4<i32> = shl %6, vec4<u32>(22u, 22u, 22u, 30u)
+    %8:vec4<i32> = shr %7, vec4<u32>(22u, 22u, 22u, 30u)
+    %9:vec4<f32> = convert %8
+    %10:vec4<f32> = div %9, vec4<f32>(511.0f, 511.0f, 511.0f, 1.0f)
+    %11:vec4<f32> = max %10, vec4<f32>(-1.0f)
+    ret %11
+  }
+}
+)";
+
+    Run(DecomposeSnorm10_10_10_2, std::vector<uint32_t>{0u});
+
+    EXPECT_EQ(expect, str());
+}
+
+TEST_F(HlslWriterDecomposeSnorm10_10_10_2Test, DecomposeStructMember) {
+    auto* inputs = ty.Struct(mod.symbols.New("Inputs"),
+                             {
+                                 {mod.symbols.New("pos"), ty.vec4f(), Location(0u)},
+                                 {mod.symbols.New("norm"), ty.vec4f(), Location(1u)},
+                             });
+
+    auto* func = b.Function("foo", ty.vec4f(), core::ir::Function::PipelineStage::kVertex);
+    func->SetReturnBuiltin(core::BuiltinValue::kPosition);
+    auto* param = b.FunctionParam("input", inputs);
+    func->AppendParam(param);
+
+    b.Append(func->Block(), [&] {
+        auto* norm_val = b.Access(ty.vec4f(), param, 1_u);
+        b.Return(func, norm_val);
+    });
+
+    auto* src = R"(
+Inputs = struct @align(16) {
+  pos:vec4<f32> @offset(0), @location(0)
+  norm:vec4<f32> @offset(16), @location(1)
+}
+
+%foo = @vertex func(%input:Inputs):vec4<f32> [@position] {
+  $B1: {
+    %3:vec4<f32> = access %input, 1u
+    ret %3
+  }
+}
+)";
+    EXPECT_EQ(src, str());
+
+    auto* expect = R"(
+Inputs = struct @align(16) {
+  pos:vec4<f32> @offset(0), @location(0)
+  norm:vec4<f32> @offset(16), @location(1)
+}
+
+%foo = @vertex func(%input:Inputs):vec4<f32> [@position] {
+  $B1: {
+    %3:vec4<f32> = access %input, 1u
+    %4:vec4<f32> = mul %3, vec4<f32>(1023.0f, 1023.0f, 1023.0f, 3.0f)
+    %5:vec4<f32> = round %4
+    %6:vec4<i32> = convert %5
+    %7:vec4<i32> = shl %6, vec4<u32>(22u, 22u, 22u, 30u)
+    %8:vec4<i32> = shr %7, vec4<u32>(22u, 22u, 22u, 30u)
+    %9:vec4<f32> = convert %8
+    %10:vec4<f32> = div %9, vec4<f32>(511.0f, 511.0f, 511.0f, 1.0f)
+    %11:vec4<f32> = max %10, vec4<f32>(-1.0f)
+    ret %11
+  }
+}
+)";
+
+    Run(DecomposeSnorm10_10_10_2, std::vector<uint32_t>{1u});
+
+    EXPECT_EQ(expect, str());
+}
+
+}  // namespace
+}  // namespace tint::hlsl::writer::raise
diff --git a/src/tint/lang/hlsl/writer/raise/raise.cc b/src/tint/lang/hlsl/writer/raise/raise.cc
index 5e7eabb..1cbe672 100644
--- a/src/tint/lang/hlsl/writer/raise/raise.cc
+++ b/src/tint/lang/hlsl/writer/raise/raise.cc
@@ -66,6 +66,7 @@
 #include "src/tint/lang/hlsl/writer/raise/array_offset_from_uniform.h"
 #include "src/tint/lang/hlsl/writer/raise/binary_polyfill.h"
 #include "src/tint/lang/hlsl/writer/raise/builtin_polyfill.h"
+#include "src/tint/lang/hlsl/writer/raise/decompose_snorm10_10_10_2.h"
 #include "src/tint/lang/hlsl/writer/raise/decompose_storage_access.h"
 #include "src/tint/lang/hlsl/writer/raise/extract_ternary_values.h"
 #include "src/tint/lang/hlsl/writer/raise/localize_struct_array_assignment.h"
@@ -290,6 +291,8 @@
         TINT_CHECK_RESULT(raise::ShaderIO(module, config));
     }
 
+    TINT_CHECK_RESULT(raise::DecomposeSnorm10_10_10_2(module, options.snorm10_10_10_2_locations));
+
     // DemoteToHelper must come before any transform that introduces non-core instructions.
     // Run after ShaderIO to ensure the discards are added to the entry point it introduces.
     // TODO(crbug.com/42250787): This is only necessary when FXC is being used.