Add the validation for PixelLocalStorage.

The AttachmentState is modified to contain the PLS state as it is part
of the compatibility between a render pass and a render pipeline.

Minimal fixes to dawn.json for GPU sizes.

Adds tests for the added validation.

Bugs found with tests:
 - The AttachmentState should different between no PLS and empty PLS.
 - Missing usage tracking.
 - Attachment state blueprint init didn't copy mHasPLS
 - Pipeline layout hash/equality wasn't updated for PLS.
 - webgpu_absl_format didn't handle no PLS vs. empty PLS.

Bug: dawn:1704
Change-Id: If56bf8748c29d13f8d2ee1290b79e039fffc790d
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/147500
Reviewed-by: Austin Eng <enga@chromium.org>
Reviewed-by: Quyen Le <lehoangquyen@chromium.org>
Kokoro: Kokoro <noreply+kokoro@google.com>
Commit-Queue: Corentin Wallez <cwallez@chromium.org>
diff --git a/dawn.json b/dawn.json
index a67b920..2b2685f 100644
--- a/dawn.json
+++ b/dawn.json
@@ -2066,7 +2066,7 @@
         "chained": "in",
         "chain roots": ["pipeline layout descriptor"],
         "members": [
-            {"name": "total pixel local storage size", "type": "size_t"},
+            {"name": "total pixel local storage size", "type": "uint64_t"},
             {"name": "storage attachment count", "type": "size_t", "default": 0},
             {"name": "storage attachments", "type": "pipeline layout storage attachment", "annotation": "const*", "length": "storage attachment count"}
         ]
@@ -2473,7 +2473,7 @@
         "chained": "in",
         "chain roots": ["render pass descriptor"],
         "members": [
-            {"name": "total pixel local storage size", "type": "size_t"},
+            {"name": "total pixel local storage size", "type": "uint64_t"},
             {"name": "storage attachment count", "type": "size_t", "default": 0},
             {"name": "storage attachments", "type": "render pass storage attachment", "annotation": "const*", "length": "storage attachment count"}
         ]
diff --git a/src/dawn/common/Constants.h b/src/dawn/common/Constants.h
index d95c8cf..f191224 100644
--- a/src/dawn/common/Constants.h
+++ b/src/dawn/common/Constants.h
@@ -65,6 +65,10 @@
 static constexpr uint8_t kSamplersPerExternalTexture = 1u;
 static constexpr uint8_t kUniformsPerExternalTexture = 1u;
 
+static constexpr uint8_t kMaxPLSSlots = 4;
+static constexpr size_t kPLSSlotByteSize = 4;
+static constexpr uint8_t kMaxPLSSize = kMaxPLSSlots * kPLSSlotByteSize;
+
 // Wire buffer alignments.
 static constexpr size_t kWireBufferAlignment = 8u;
 
diff --git a/src/dawn/native/AttachmentState.cpp b/src/dawn/native/AttachmentState.cpp
index 2492dba..87c1b4e 100644
--- a/src/dawn/native/AttachmentState.cpp
+++ b/src/dawn/native/AttachmentState.cpp
@@ -18,6 +18,7 @@
 #include "dawn/native/ChainUtils_autogen.h"
 #include "dawn/native/Device.h"
 #include "dawn/native/ObjectContentHasher.h"
+#include "dawn/native/PipelineLayout.h"
 #include "dawn/native/Texture.h"
 
 namespace dawn::native {
@@ -36,12 +37,15 @@
     }
     mDepthStencilFormat = descriptor->depthStencilFormat;
 
-    // TODO(dawn:1710): support MSAA render to single sampled in render bundle.
+    // TODO(dawn:1710): support MSAA render to single sampled in render bundles.
+    // TODO(dawn:1704): support PLS in render bundles.
 
     SetContentHash(ComputeContentHash());
 }
 
-AttachmentState::AttachmentState(DeviceBase* device, const RenderPipelineDescriptor* descriptor)
+AttachmentState::AttachmentState(DeviceBase* device,
+                                 const RenderPipelineDescriptor* descriptor,
+                                 const PipelineLayoutBase* layout)
     : ObjectBase(device), mSampleCount(descriptor->multisample.count) {
     const DawnMultisampleStateRenderToSingleSampled* msaaRenderToSingleSampledDesc = nullptr;
     FindInChain(descriptor->multisample.nextInChain, &msaaRenderToSingleSampledDesc);
@@ -65,6 +69,10 @@
     if (descriptor->depthStencil != nullptr) {
         mDepthStencilFormat = descriptor->depthStencil->format;
     }
+
+    mHasPLS = layout->HasPixelLocalStorage();
+    mStorageAttachmentSlots = layout->GetStorageAttachmentSlots();
+
     SetContentHash(ComputeContentHash());
 }
 
@@ -109,6 +117,20 @@
         }
     }
     ASSERT(mSampleCount > 0);
+
+    // Gather the PLS information.
+    const RenderPassPixelLocalStorage* pls = nullptr;
+    FindInChain(descriptor->nextInChain, &pls);
+    if (pls != nullptr) {
+        mHasPLS = true;
+        mStorageAttachmentSlots = std::vector<wgpu::TextureFormat>(
+            pls->totalPixelLocalStorageSize / kPLSSlotByteSize, wgpu::TextureFormat::Undefined);
+        for (size_t i = 0; i < pls->storageAttachmentCount; i++) {
+            size_t slot = pls->storageAttachments[i].offset / kPLSSlotByteSize;
+            mStorageAttachmentSlots[slot] = pls->storageAttachments[i].storage->GetFormat().format;
+        }
+    }
+
     SetContentHash(ComputeContentHash());
 }
 
@@ -119,6 +141,8 @@
     mDepthStencilFormat = blueprint.mDepthStencilFormat;
     mSampleCount = blueprint.mSampleCount;
     mIsMSAARenderToSingleSampledEnabled = blueprint.mIsMSAARenderToSingleSampledEnabled;
+    mHasPLS = blueprint.mHasPLS;
+    mStorageAttachmentSlots = blueprint.mStorageAttachmentSlots;
     SetContentHash(blueprint.GetContentHash());
 }
 
@@ -156,6 +180,19 @@
         return false;
     }
 
+    // Check PLS
+    if (a->mHasPLS != b->mHasPLS) {
+        return false;
+    }
+    if (a->mStorageAttachmentSlots.size() != b->mStorageAttachmentSlots.size()) {
+        return false;
+    }
+    for (size_t i = 0; i < a->mStorageAttachmentSlots.size(); i++) {
+        if (a->mStorageAttachmentSlots[i] != b->mStorageAttachmentSlots[i]) {
+            return false;
+        }
+    }
+
     return true;
 }
 
@@ -177,6 +214,12 @@
     // Hash MSAA render to single sampled flag
     HashCombine(&hash, mIsMSAARenderToSingleSampledEnabled);
 
+    // Hash the PLS state
+    HashCombine(&hash, mHasPLS);
+    for (wgpu::TextureFormat slotFormat : mStorageAttachmentSlots) {
+        HashCombine(&hash, slotFormat);
+    }
+
     return hash;
 }
 
@@ -207,4 +250,11 @@
     return mIsMSAARenderToSingleSampledEnabled;
 }
 
+bool AttachmentState::HasPixelLocalStorage() const {
+    return mHasPLS;
+}
+
+const std::vector<wgpu::TextureFormat>& AttachmentState::GetStorageAttachmentSlots() const {
+    return mStorageAttachmentSlots;
+}
 }  // namespace dawn::native
diff --git a/src/dawn/native/AttachmentState.h b/src/dawn/native/AttachmentState.h
index c17faae..60f3d41 100644
--- a/src/dawn/native/AttachmentState.h
+++ b/src/dawn/native/AttachmentState.h
@@ -17,6 +17,7 @@
 
 #include <array>
 #include <bitset>
+#include <vector>
 
 #include "dawn/common/Constants.h"
 #include "dawn/common/ContentLessObjectCacheable.h"
@@ -38,7 +39,9 @@
   public:
     // Note: Descriptors must be validated before the AttachmentState is constructed.
     explicit AttachmentState(DeviceBase* device, const RenderBundleEncoderDescriptor* descriptor);
-    explicit AttachmentState(DeviceBase* device, const RenderPipelineDescriptor* descriptor);
+    explicit AttachmentState(DeviceBase* device,
+                             const RenderPipelineDescriptor* descriptor,
+                             const PipelineLayoutBase* layout);
     explicit AttachmentState(DeviceBase* device, const RenderPassDescriptor* descriptor);
 
     // Constructor used to avoid re-parsing descriptors when we already parsed them for cache keys.
@@ -50,6 +53,8 @@
     wgpu::TextureFormat GetDepthStencilFormat() const;
     uint32_t GetSampleCount() const;
     bool IsMSAARenderToSingleSampledEnabled() const;
+    bool HasPixelLocalStorage() const;
+    const std::vector<wgpu::TextureFormat>& GetStorageAttachmentSlots() const;
 
     struct EqualityFunc {
         bool operator()(const AttachmentState* a, const AttachmentState* b) const;
@@ -67,6 +72,8 @@
     uint32_t mSampleCount = 0;
 
     bool mIsMSAARenderToSingleSampledEnabled = false;
+    bool mHasPLS = false;
+    std::vector<wgpu::TextureFormat> mStorageAttachmentSlots;
 };
 
 }  // namespace dawn::native
diff --git a/src/dawn/native/CommandEncoder.cpp b/src/dawn/native/CommandEncoder.cpp
index f8e6d05..8ce1f07 100644
--- a/src/dawn/native/CommandEncoder.cpp
+++ b/src/dawn/native/CommandEncoder.cpp
@@ -365,7 +365,7 @@
     if (colorAttachment.loadOp == wgpu::LoadOp::Clear) {
         DAWN_INVALID_IF(std::isnan(clearValue.r) || std::isnan(clearValue.g) ||
                             std::isnan(clearValue.b) || std::isnan(clearValue.a),
-                        "Color clear value (%s) contain a NaN.", &clearValue);
+                        "Color clear value (%s) contains a NaN.", &clearValue);
     }
 
     DAWN_TRY(
@@ -522,6 +522,49 @@
     return {};
 }
 
+MaybeError ValidateRenderPassPLS(DeviceBase* device,
+                                 const RenderPassPixelLocalStorage* pls,
+                                 uint32_t* width,
+                                 uint32_t* height,
+                                 uint32_t* sampleCount,
+                                 uint32_t implicitSampleCount,
+                                 UsageValidationMode usageValidationMode) {
+    StackVector<StorageAttachmentInfoForValidation, 4> attachments;
+    for (size_t i = 0; i < pls->storageAttachmentCount; i++) {
+        const RenderPassStorageAttachment& attachment = pls->storageAttachments[i];
+
+        // Validate the attachment can be used as a storage attachment.
+        DAWN_TRY(device->ValidateObject(attachment.storage));
+        DAWN_TRY(ValidateCanUseAs(attachment.storage->GetTexture(),
+                                  wgpu::TextureUsage::StorageAttachment, usageValidationMode));
+        DAWN_TRY(ValidateAttachmentArrayLayersAndLevelCount(attachment.storage));
+        DAWN_TRY(ValidateOrSetColorAttachmentSampleCount(attachment.storage, implicitSampleCount,
+                                                         sampleCount));
+        DAWN_TRY(ValidateOrSetAttachmentSize(attachment.storage, width, height));
+
+        // Validate the load/storeOp and the clearValue.
+        DAWN_TRY(ValidateLoadOp(attachment.loadOp));
+        DAWN_TRY(ValidateStoreOp(attachment.storeOp));
+        DAWN_INVALID_IF(attachment.loadOp == wgpu::LoadOp::Undefined,
+                        "storageAttachments[%i].loadOp must be set.", i);
+        DAWN_INVALID_IF(attachment.storeOp == wgpu::StoreOp::Undefined,
+                        "storageAttachments[%i].storeOp must be set.", i);
+
+        const dawn::native::Color& clearValue = attachment.clearValue;
+        if (attachment.loadOp == wgpu::LoadOp::Clear) {
+            DAWN_INVALID_IF(std::isnan(clearValue.r) || std::isnan(clearValue.g) ||
+                                std::isnan(clearValue.b) || std::isnan(clearValue.a),
+                            "storageAttachments[%i].clearValue (%s) contains a NaN.", i,
+                            &clearValue);
+        }
+
+        attachments->push_back({attachment.offset, attachment.storage->GetFormat().format});
+    }
+
+    return ValidatePLSInfo(device, pls->totalPixelLocalStorageSize,
+                           {attachments->data(), attachments->size()});
+}
+
 MaybeError ValidateRenderPassDescriptor(DeviceBase* device,
                                         const RenderPassDescriptor* descriptor,
                                         uint32_t* width,
@@ -612,9 +655,20 @@
         }
     }
 
-    DAWN_INVALID_IF(
-        descriptor->colorAttachmentCount == 0 && descriptor->depthStencilAttachment == nullptr,
-        "Render pass has no attachments.");
+    // Validation for any pixel local storage.
+    size_t storageAttachmentCount = 0;
+    const RenderPassPixelLocalStorage* pls = nullptr;
+    FindInChain(descriptor->nextInChain, &pls);
+    if (pls != nullptr) {
+        storageAttachmentCount = pls->storageAttachmentCount;
+        DAWN_TRY(ValidateRenderPassPLS(device, pls, width, height, sampleCount,
+                                       *implicitSampleCount, usageValidationMode));
+    }
+
+    DAWN_INVALID_IF(descriptor->colorAttachmentCount == 0 &&
+                        descriptor->depthStencilAttachment == nullptr &&
+                        storageAttachmentCount == 0,
+                    "Render pass has no attachments.");
 
     if (*implicitSampleCount > 1) {
         // TODO(dawn:1710): support multiple attachments.
@@ -623,14 +677,9 @@
             "colorAttachmentCount (%u) is not supported when the render pass has implicit sample "
             "count (%u). (Currently) colorAttachmentCount = 1 is supported.",
             descriptor->colorAttachmentCount, *implicitSampleCount);
-    }
-
-    const RenderPassPixelLocalStorage* pls = nullptr;
-    FindInChain(descriptor->nextInChain, &pls);
-    if (pls != nullptr) {
-        DAWN_TRY(ValidateHasPLSFeature(device));
-
-        // TODO(dawn:1704): Validate limits, formats, offsets don't collide and the total size.
+        // TODO(dawn:1704): Consider supporting MSAARenderToSingleSampled + PLS
+        DAWN_INVALID_IF(pls != nullptr,
+                        "For now PLS is invalid to use with MSAARenderToSingleSampled.");
     }
 
     return {};
@@ -1195,6 +1244,15 @@
                 usageTracker.TrackQueryAvailability(querySet, queryIndex);
             }
 
+            const RenderPassPixelLocalStorage* pls = nullptr;
+            FindInChain(descriptor->nextInChain, &pls);
+            if (pls != nullptr) {
+                for (size_t i = 0; i < pls->storageAttachmentCount; i++) {
+                    usageTracker.TextureViewUsedAs(pls->storageAttachments[i].storage,
+                                                   wgpu::TextureUsage::StorageAttachment);
+                }
+            }
+
             DAWN_TRY_ASSIGN(passEndCallback,
                             ApplyRenderPassWorkarounds(device, &usageTracker, cmd));
 
diff --git a/src/dawn/native/CommandValidation.cpp b/src/dawn/native/CommandValidation.cpp
index 26e7899..4a9b44a 100644
--- a/src/dawn/native/CommandValidation.cpp
+++ b/src/dawn/native/CommandValidation.cpp
@@ -543,12 +543,56 @@
     return {};
 }
 
-MaybeError ValidateHasPLSFeature(const DeviceBase* device) {
+MaybeError ValidatePLSInfo(
+    const DeviceBase* device,
+    uint64_t totalSize,
+    ityp::span<size_t, StorageAttachmentInfoForValidation> storageAttachments) {
     DAWN_INVALID_IF(
         !(device->HasFeature(Feature::PixelLocalStorageCoherent) ||
           device->HasFeature(Feature::PixelLocalStorageNonCoherent)),
         "Pixel Local Storage feature used without either of the pixel-local-storage-coherent or "
         "pixel-local-storage-non-coherent features enabled.");
+
+    // Validate totalPixelLocalStorageSize
+    DAWN_INVALID_IF(totalSize % kPLSSlotByteSize != 0,
+                    "totalPixelLocalStorageSize (%i) is not a multiple of %i.", totalSize,
+                    kPLSSlotByteSize);
+    DAWN_INVALID_IF(totalSize > kMaxPLSSize,
+                    "totalPixelLocalStorageSize (%i) is larger than maxPixelLocalStorageSize (%i).",
+                    totalSize, kMaxPLSSize);
+
+    std::array<size_t, kMaxPLSSlots> indexForSlot;
+    constexpr size_t kSlotNotSet = std::numeric_limits<size_t>::max();
+    indexForSlot.fill(kSlotNotSet);
+    for (size_t i = 0; i < storageAttachments.size(); i++) {
+        const Format& format = device->GetValidInternalFormat(storageAttachments[i].format);
+        ASSERT(format.supportsStorageAttachment);
+
+        // Validate the slot's offset.
+        uint64_t offset = storageAttachments[i].offset;
+        DAWN_INVALID_IF(offset % kPLSSlotByteSize != 0,
+                        "storageAttachments[%i].offset (%i) is not a multiple of %i.", i, offset,
+                        kPLSSlotByteSize);
+        DAWN_INVALID_IF(
+            offset > kMaxPLSSize,
+            "storageAttachments[%i].offset (%i) is larger than maxPixelLocalStorageSize (%i).", i,
+            offset, kMaxPLSSize);
+        // This can't overflow because kMaxPLSSize + max texel byte size is way less than 2^32.
+        DAWN_INVALID_IF(
+            offset + format.GetAspectInfo(Aspect::Color).block.byteSize > totalSize,
+            "storageAttachments[%i]'s footprint [%i, %i) does not fit in the total size (%i).", i,
+            offset, format.GetAspectInfo(Aspect::Color).block.byteSize, totalSize);
+
+        // Validate that there are no collisions, each storage attachment takes a single slot so
+        // we don't need to loop over all slots for a storage attachment.
+        ASSERT(format.GetAspectInfo(Aspect::Color).block.byteSize == kPLSSlotByteSize);
+        size_t slot = offset / kPLSSlotByteSize;
+        DAWN_INVALID_IF(indexForSlot[slot] != kSlotNotSet,
+                        "storageAttachments[%i] and storageAttachment[%i] conflict.", i,
+                        indexForSlot[slot]);
+        indexForSlot[slot] = i;
+    }
+
     return {};
 }
 
diff --git a/src/dawn/native/CommandValidation.h b/src/dawn/native/CommandValidation.h
index 0d24590..b0e954b 100644
--- a/src/dawn/native/CommandValidation.h
+++ b/src/dawn/native/CommandValidation.h
@@ -99,7 +99,15 @@
 MaybeError ValidateColorAttachmentBytesPerSample(DeviceBase* device,
                                                  const ColorAttachmentFormats& formats);
 
-MaybeError ValidateHasPLSFeature(const DeviceBase* device);
+struct StorageAttachmentInfoForValidation {
+    uint64_t offset;
+    // This format is assumed to support StorageAttachment.
+    wgpu::TextureFormat format;
+};
+MaybeError ValidatePLSInfo(
+    const DeviceBase* device,
+    uint64_t totalSize,
+    ityp::span<size_t, StorageAttachmentInfoForValidation> storageAttachments);
 
 }  // namespace dawn::native
 
diff --git a/src/dawn/native/Device.cpp b/src/dawn/native/Device.cpp
index d441d66..1700cd4 100644
--- a/src/dawn/native/Device.cpp
+++ b/src/dawn/native/Device.cpp
@@ -1007,8 +1007,9 @@
 }
 
 Ref<AttachmentState> DeviceBase::GetOrCreateAttachmentState(
-    const RenderPipelineDescriptor* descriptor) {
-    AttachmentState blueprint(this, descriptor);
+    const RenderPipelineDescriptor* descriptor,
+    const PipelineLayoutBase* layout) {
+    AttachmentState blueprint(this, descriptor, layout);
     return GetOrCreateAttachmentState(&blueprint);
 }
 
diff --git a/src/dawn/native/Device.h b/src/dawn/native/Device.h
index 6fdddf0..3081c75 100644
--- a/src/dawn/native/Device.h
+++ b/src/dawn/native/Device.h
@@ -216,7 +216,8 @@
     Ref<AttachmentState> GetOrCreateAttachmentState(AttachmentState* blueprint);
     Ref<AttachmentState> GetOrCreateAttachmentState(
         const RenderBundleEncoderDescriptor* descriptor);
-    Ref<AttachmentState> GetOrCreateAttachmentState(const RenderPipelineDescriptor* descriptor);
+    Ref<AttachmentState> GetOrCreateAttachmentState(const RenderPipelineDescriptor* descriptor,
+                                                    const PipelineLayoutBase* layout);
     Ref<AttachmentState> GetOrCreateAttachmentState(const RenderPassDescriptor* descriptor);
 
     Ref<PipelineCacheBase> GetOrCreatePipelineCache(const CacheKey& key);
diff --git a/src/dawn/native/Format.cpp b/src/dawn/native/Format.cpp
index e94634d..7a60cc4 100644
--- a/src/dawn/native/Format.cpp
+++ b/src/dawn/native/Format.cpp
@@ -30,6 +30,7 @@
     Resolve = 0x4,
     StorageW = 0x8,
     StorageRW = 0x10,  // Implies StorageW
+    PLS = 0x20,
 };
 
 template <>
@@ -254,6 +255,7 @@
             }
             internalFormat.supportsMultisample = supportsMultisample;
             internalFormat.supportsResolveTarget = capabilities & Cap::Resolve;
+            internalFormat.supportsStorageAttachment = capabilities & Cap::PLS;
             internalFormat.aspects = Aspect::Color;
             internalFormat.componentCount = static_cast<uint32_t>(componentCount);
             if (renderable) {
@@ -455,10 +457,11 @@
     // 4 bytes color formats
     SampleTypeBit sampleTypeFor32BitFloatFormats = device->HasFeature(Feature::Float32Filterable) ? kAnyFloat : SampleTypeBit::UnfilterableFloat;
     auto supportsReadWriteStorageUsage = device->HasFeature(Feature::ChromiumExperimentalReadWriteStorageTexture) ? Cap::StorageRW : Cap::None;
+    auto supportsPLS = device->HasFeature(Feature::PixelLocalStorageCoherent) || device->HasFeature(Feature::PixelLocalStorageNonCoherent) ? Cap::PLS : Cap::None;
 
-    AddColorFormat(wgpu::TextureFormat::R32Uint, Cap::Renderable | Cap::StorageW | supportsReadWriteStorageUsage, ByteSize(4), SampleTypeBit::Uint, ComponentCount(1), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(4));
-    AddColorFormat(wgpu::TextureFormat::R32Sint, Cap::Renderable | Cap::StorageW | supportsReadWriteStorageUsage, ByteSize(4), SampleTypeBit::Sint, ComponentCount(1), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(4));
-    AddColorFormat(wgpu::TextureFormat::R32Float, Cap::Renderable | Cap::Multisample | Cap::StorageW | supportsReadWriteStorageUsage, ByteSize(4), sampleTypeFor32BitFloatFormats, ComponentCount(1), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(4));
+    AddColorFormat(wgpu::TextureFormat::R32Uint, Cap::Renderable | Cap::StorageW | supportsReadWriteStorageUsage | supportsPLS, ByteSize(4), SampleTypeBit::Uint, ComponentCount(1), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(4));
+    AddColorFormat(wgpu::TextureFormat::R32Sint, Cap::Renderable | Cap::StorageW | supportsReadWriteStorageUsage | supportsPLS, ByteSize(4), SampleTypeBit::Sint, ComponentCount(1), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(4));
+    AddColorFormat(wgpu::TextureFormat::R32Float, Cap::Renderable | Cap::Multisample | Cap::StorageW | supportsReadWriteStorageUsage | supportsPLS, ByteSize(4), sampleTypeFor32BitFloatFormats, ComponentCount(1), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(4));
     AddColorFormat(wgpu::TextureFormat::RG16Uint, Cap::Renderable | Cap::Multisample, ByteSize(4), SampleTypeBit::Uint, ComponentCount(2), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(2));
     AddColorFormat(wgpu::TextureFormat::RG16Sint, Cap::Renderable | Cap::Multisample, ByteSize(4), SampleTypeBit::Sint, ComponentCount(2), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(2));
     AddColorFormat(wgpu::TextureFormat::RG16Float, Cap::Renderable | Cap::Multisample | Cap::Resolve, ByteSize(4), kAnyFloat, ComponentCount(2), RenderTargetPixelByteCost(4), RenderTargetComponentAlignment(2));
diff --git a/src/dawn/native/Format.h b/src/dawn/native/Format.h
index c367314..6562675 100644
--- a/src/dawn/native/Format.h
+++ b/src/dawn/native/Format.h
@@ -115,6 +115,7 @@
     bool supportsReadWriteStorageUsage = false;
     bool supportsMultisample = false;
     bool supportsResolveTarget = false;
+    bool supportsStorageAttachment = false;
     Aspect aspects{};
     // Only used for renderable color formats:
     uint8_t componentCount = 0;                  // number of color channels
diff --git a/src/dawn/native/PassResourceUsageTracker.cpp b/src/dawn/native/PassResourceUsageTracker.cpp
index 3d2d0bf..e848873 100644
--- a/src/dawn/native/PassResourceUsageTracker.cpp
+++ b/src/dawn/native/PassResourceUsageTracker.cpp
@@ -53,16 +53,17 @@
     TextureSubresourceUsage& textureUsage = it.first->second;
 
     textureUsage.Update(range, [usage](const SubresourceRange&, wgpu::TextureUsage* storedUsage) {
-        // TODO(crbug.com/dawn/1001): Consider optimizing to have fewer
-        // branches.
-        if ((*storedUsage & wgpu::TextureUsage::RenderAttachment) != 0 &&
-            (usage & wgpu::TextureUsage::RenderAttachment) != 0) {
-            // Using the same subresource as an attachment for two different
-            // render attachments is a write-write hazard. Add this internal
-            // usage so we will fail the check that a subresource with
-            // writable usage is the single usage.
-            *storedUsage |= kAgainAsRenderAttachment;
+        // TODO(crbug.com/dawn/1001): Consider optimizing to have fewer branches.
+
+        // Using the same subresource for two different attachments is a write-write or read-write
+        // hazard. Add the internal kAgainAsAttachment usage to fail the later check that a
+        // subresource with a writable usage has a single usage.
+        constexpr wgpu::TextureUsage kWritableAttachmentUsages =
+            wgpu::TextureUsage::RenderAttachment | wgpu::TextureUsage::StorageAttachment;
+        if ((usage & kWritableAttachmentUsages) && (*storedUsage & kWritableAttachmentUsages)) {
+            *storedUsage |= kAgainAsAttachment;
         }
+
         *storedUsage |= usage;
     });
 }
diff --git a/src/dawn/native/PipelineLayout.cpp b/src/dawn/native/PipelineLayout.cpp
index f723fcc..7869215 100644
--- a/src/dawn/native/PipelineLayout.cpp
+++ b/src/dawn/native/PipelineLayout.cpp
@@ -35,12 +35,26 @@
 MaybeError ValidatePipelineLayoutDescriptor(DeviceBase* device,
                                             const PipelineLayoutDescriptor* descriptor,
                                             PipelineCompatibilityToken pipelineCompatibilityToken) {
+    // Validation for any pixel local storage.
     const PipelineLayoutPixelLocalStorage* pls = nullptr;
     FindInChain(descriptor->nextInChain, &pls);
     if (pls != nullptr) {
-        DAWN_TRY(ValidateHasPLSFeature(device));
+        StackVector<StorageAttachmentInfoForValidation, 4> attachments;
+        for (size_t i = 0; i < pls->storageAttachmentCount; i++) {
+            const PipelineLayoutStorageAttachment& attachment = pls->storageAttachments[i];
 
-        // TODO(dawn:1704): Validate limits, formats, offsets don't collide and the total size.
+            const Format* format;
+            DAWN_TRY_ASSIGN_CONTEXT(format, device->GetInternalFormat(attachment.format),
+                                    "validating storageAttachments[%i]", i);
+            DAWN_INVALID_IF(!format->supportsStorageAttachment,
+                            "storageAttachments[%i]'s format (%s) cannot be used with %s.", i,
+                            format->format, wgpu::TextureUsage::StorageAttachment);
+
+            attachments->push_back({attachment.offset, attachment.format});
+        }
+
+        DAWN_TRY(ValidatePLSInfo(device, pls->totalPixelLocalStorageSize,
+                                 {attachments->data(), attachments->size()}));
     }
 
     DAWN_INVALID_IF(descriptor->bindGroupLayoutCount > kMaxBindGroups,
@@ -78,6 +92,19 @@
         mBindGroupLayouts[group] = descriptor->bindGroupLayouts[static_cast<uint32_t>(group)];
         mMask.set(group);
     }
+
+    // Gather the PLS information.
+    const PipelineLayoutPixelLocalStorage* pls = nullptr;
+    FindInChain(descriptor->nextInChain, &pls);
+    if (pls != nullptr) {
+        mHasPLS = true;
+        mStorageAttachmentSlots = std::vector<wgpu::TextureFormat>(
+            pls->totalPixelLocalStorageSize / kPLSSlotByteSize, wgpu::TextureFormat::Undefined);
+        for (size_t i = 0; i < pls->storageAttachmentCount; i++) {
+            size_t slot = pls->storageAttachments[i].offset / kPLSSlotByteSize;
+            mStorageAttachmentSlots[slot] = pls->storageAttachments[i].format;
+        }
+    }
 }
 
 PipelineLayoutBase::PipelineLayoutBase(DeviceBase* device,
@@ -378,6 +405,14 @@
     return mMask;
 }
 
+bool PipelineLayoutBase::HasPixelLocalStorage() const {
+    return mHasPLS;
+}
+
+const std::vector<wgpu::TextureFormat>& PipelineLayoutBase::GetStorageAttachmentSlots() const {
+    return mStorageAttachmentSlots;
+}
+
 BindGroupLayoutMask PipelineLayoutBase::InheritedGroupsMask(const PipelineLayoutBase* other) const {
     ASSERT(!IsError());
     return {(1 << static_cast<uint32_t>(GroupsInheritUpTo(other))) - 1u};
@@ -402,6 +437,12 @@
         recorder.Record(GetBindGroupLayout(group)->GetContentHash());
     }
 
+    // Hash the PLS state
+    recorder.Record(mHasPLS);
+    for (wgpu::TextureFormat slotFormat : mStorageAttachmentSlots) {
+        recorder.Record(slotFormat);
+    }
+
     return recorder.GetContentHash();
 }
 
@@ -417,6 +458,19 @@
         }
     }
 
+    // Check PLS
+    if (a->mHasPLS != b->mHasPLS) {
+        return false;
+    }
+    if (a->mStorageAttachmentSlots.size() != b->mStorageAttachmentSlots.size()) {
+        return false;
+    }
+    for (size_t i = 0; i < a->mStorageAttachmentSlots.size(); i++) {
+        if (a->mStorageAttachmentSlots[i] != b->mStorageAttachmentSlots[i]) {
+            return false;
+        }
+    }
+
     return true;
 }
 
diff --git a/src/dawn/native/PipelineLayout.h b/src/dawn/native/PipelineLayout.h
index 8a0c86f..9b005d6 100644
--- a/src/dawn/native/PipelineLayout.h
+++ b/src/dawn/native/PipelineLayout.h
@@ -72,6 +72,8 @@
     const BindGroupLayoutInternalBase* GetBindGroupLayout(BindGroupIndex group) const;
     BindGroupLayoutInternalBase* GetBindGroupLayout(BindGroupIndex group);
     const BindGroupLayoutMask& GetBindGroupLayoutsMask() const;
+    bool HasPixelLocalStorage() const;
+    const std::vector<wgpu::TextureFormat>& GetStorageAttachmentSlots() const;
 
     // Utility functions to compute inherited bind groups.
     // Returns the inherited bind groups as a mask.
@@ -94,6 +96,8 @@
 
     BindGroupLayoutArray mBindGroupLayouts;
     BindGroupLayoutMask mMask;
+    bool mHasPLS = false;
+    std::vector<wgpu::TextureFormat> mStorageAttachmentSlots;
 };
 
 }  // namespace dawn::native
diff --git a/src/dawn/native/RenderPassEncoder.cpp b/src/dawn/native/RenderPassEncoder.cpp
index b0e70a3..01545f3 100644
--- a/src/dawn/native/RenderPassEncoder.cpp
+++ b/src/dawn/native/RenderPassEncoder.cpp
@@ -445,7 +445,8 @@
         this,
         [&](CommandAllocator* allocator) -> MaybeError {
             if (IsValidationEnabled()) {
-                DAWN_TRY(ValidateHasPLSFeature(GetDevice()));
+                DAWN_INVALID_IF(!GetAttachmentState()->HasPixelLocalStorage(),
+                                "%s does not define any pixel local storage.", this);
             }
 
             allocator->Allocate<PixelLocalStorageBarrierCmd>(Command::PixelLocalStorageBarrier);
diff --git a/src/dawn/native/RenderPipeline.cpp b/src/dawn/native/RenderPipeline.cpp
index a0d97da..bd75e8d 100644
--- a/src/dawn/native/RenderPipeline.cpp
+++ b/src/dawn/native/RenderPipeline.cpp
@@ -693,7 +693,7 @@
                    descriptor->layout,
                    descriptor->label,
                    GetRenderStagesAndSetPlaceholderShader(device, descriptor)),
-      mAttachmentState(device->GetOrCreateAttachmentState(descriptor)) {
+      mAttachmentState(device->GetOrCreateAttachmentState(descriptor, GetLayout())) {
     mVertexBufferCount = descriptor->vertex.bufferCount;
     const VertexBufferLayout* buffers = descriptor->vertex.buffers;
     for (uint8_t slot = 0; slot < mVertexBufferCount; ++slot) {
diff --git a/src/dawn/native/Texture.cpp b/src/dawn/native/Texture.cpp
index a7d04c0..b7397da 100644
--- a/src/dawn/native/Texture.cpp
+++ b/src/dawn/native/Texture.cpp
@@ -171,7 +171,10 @@
         ASSERT(!format->isCompressed);
 
         DAWN_INVALID_IF(usage & wgpu::TextureUsage::StorageBinding,
-                        "The sample count (%u) of a storage textures is not 1.",
+                        "The sample count (%u) of a storage texture is not 1.",
+                        descriptor->sampleCount);
+        DAWN_INVALID_IF(usage & wgpu::TextureUsage::StorageAttachment,
+                        "The sample count (%u) of a storage attachment texture is not 1.",
                         descriptor->sampleCount);
 
         DAWN_INVALID_IF((usage & wgpu::TextureUsage::RenderAttachment) == 0,
@@ -335,6 +338,11 @@
         "The texture usage (%s) includes %s, which is incompatible with the format (%s).", usage,
         wgpu::TextureUsage::StorageBinding, format->format);
 
+    DAWN_INVALID_IF(
+        !format->supportsStorageAttachment && (usage & wgpu::TextureUsage::StorageAttachment),
+        "The texture usage (%s) includes %s, which is incompatible with the format (%s).", usage,
+        wgpu::TextureUsage::StorageAttachment, format->format);
+
     const auto kTransientAttachment = wgpu::TextureUsage::TransientAttachment;
     if (usage & kTransientAttachment) {
         DAWN_INVALID_IF(
@@ -350,13 +358,6 @@
                         usage, kTransientAttachment, kAllowedTransientUsage);
     }
 
-    if (usage & wgpu::TextureUsage::StorageAttachment) {
-        DAWN_TRY_CONTEXT(ValidateHasPLSFeature(device), "validating usage of %s",
-                         wgpu::TextureUsage::StorageAttachment);
-
-        // TODO(dawn:1704): Validate the constraints on the dimension, format, etc.
-    }
-
     if (!allowedSharedTextureMemoryUsage) {
         // Legacy path
         // TODO(crbug.com/dawn/1795): Remove after migrating all old usages.
diff --git a/src/dawn/native/dawn_platform.h b/src/dawn/native/dawn_platform.h
index a6f6036..ebb1bcd 100644
--- a/src/dawn/native/dawn_platform.h
+++ b/src/dawn/native/dawn_platform.h
@@ -58,7 +58,7 @@
 
 // Internal usage to help tracking when a subresource is used as render attachment usage
 // more than once in a render pass.
-static constexpr wgpu::TextureUsage kAgainAsRenderAttachment =
+static constexpr wgpu::TextureUsage kAgainAsAttachment =
     static_cast<wgpu::TextureUsage>(0x80000001);
 
 // Add an extra texture usage (load resolve texture to MSAA) for render pass resource tracking
diff --git a/src/dawn/native/webgpu_absl_format.cpp b/src/dawn/native/webgpu_absl_format.cpp
index ff613e7..f3d1dc5 100644
--- a/src/dawn/native/webgpu_absl_format.cpp
+++ b/src/dawn/native/webgpu_absl_format.cpp
@@ -15,6 +15,7 @@
 #include "dawn/native/webgpu_absl_format.h"
 
 #include <string>
+#include <vector>
 
 #include "dawn/native/AttachmentState.h"
 #include "dawn/native/BindingInfo.h"
@@ -233,6 +234,20 @@
                                   value->IsMSAARenderToSingleSampledEnabled()));
     }
 
+    if (value->HasPixelLocalStorage()) {
+        const std::vector<wgpu::TextureFormat>& plsSlots = value->GetStorageAttachmentSlots();
+        s->Append(absl::StrFormat(", totalPixelLocalStorageSize: %d",
+                                  plsSlots.size() * kPLSSlotByteSize));
+        s->Append(", storageAttachments: [ ");
+        for (size_t i = 0; i < plsSlots.size(); i++) {
+            if (plsSlots[i] != wgpu::TextureFormat::Undefined) {
+                s->Append(absl::StrFormat("{format: %s, offset: %d}, ", plsSlots[i],
+                                          i * kPLSSlotByteSize));
+            }
+        }
+        s->Append("]");
+    }
+
     s->Append(" }");
 
     return {true};
diff --git a/src/dawn/tests/unittests/validation/PixelLocalStorageTests.cpp b/src/dawn/tests/unittests/validation/PixelLocalStorageTests.cpp
index 38ccfea..a422bfb 100644
--- a/src/dawn/tests/unittests/validation/PixelLocalStorageTests.cpp
+++ b/src/dawn/tests/unittests/validation/PixelLocalStorageTests.cpp
@@ -12,8 +12,11 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-#include "dawn/tests/unittests/validation/ValidationTest.h"
+#include <vector>
 
+#include "dawn/common/NonCopyable.h"
+#include "dawn/tests/unittests/validation/ValidationTest.h"
+#include "dawn/utils/ComboRenderPipelineDescriptor.h"
 #include "dawn/utils/WGPUHelpers.h"
 
 namespace dawn {
@@ -25,7 +28,7 @@
 TEST_F(PixelLocalStorageDisabledTest, StorageAttachmentTextureNotAllowed) {
     wgpu::TextureDescriptor desc;
     desc.size = {1, 1, 1};
-    desc.format = wgpu::TextureFormat::RGBA8Unorm;
+    desc.format = wgpu::TextureFormat::R32Uint;
     desc.usage = wgpu::TextureUsage::TextureBinding;
 
     // Control case: creating the texture without StorageAttachment is allowed.
@@ -91,5 +94,738 @@
     ASSERT_DEVICE_ERROR(encoder.Finish());
 }
 
+class PixelLocalStorageOtherExtensionTest : public ValidationTest {
+  protected:
+    WGPUDevice CreateTestDevice(native::Adapter dawnAdapter,
+                                wgpu::DeviceDescriptor descriptor) override {
+        // Only test the coherent extension. The non-coherent one has the rest of the validation
+        // tests.
+        wgpu::FeatureName requiredFeatures[1] = {wgpu::FeatureName::PixelLocalStorageCoherent};
+        descriptor.requiredFeatures = requiredFeatures;
+        descriptor.requiredFeatureCount = 1;
+        return dawnAdapter.CreateDevice(&descriptor);
+    }
+};
+
+// Simple test checking all the various things that are normally validated out without PLS are
+// available if the coherent PLS extension is enabled.
+TEST_F(PixelLocalStorageOtherExtensionTest, SmokeTest) {
+    // Creating a StorageAttachment texture is allowed.
+    wgpu::TextureDescriptor textureDesc;
+    textureDesc.size = {1, 1, 1};
+    textureDesc.format = wgpu::TextureFormat::R32Uint;
+    textureDesc.usage = wgpu::TextureUsage::StorageAttachment;
+    wgpu::Texture tex = device.CreateTexture(&textureDesc);
+
+    // Creating a pipeline layout with PLS is allowed.
+    wgpu::PipelineLayoutPixelLocalStorage plPlsDesc;
+    plPlsDesc.totalPixelLocalStorageSize = 0;
+    plPlsDesc.storageAttachmentCount = 0;
+
+    wgpu::PipelineLayoutDescriptor plDesc;
+    plDesc.bindGroupLayoutCount = 0;
+    plDesc.nextInChain = &plPlsDesc;
+    device.CreatePipelineLayout(&plDesc);
+
+    // Creating a PLS render pass is allowed.
+    wgpu::RenderPassPixelLocalStorage rpPlsDesc;
+    rpPlsDesc.totalPixelLocalStorageSize = 0;
+    rpPlsDesc.storageAttachmentCount = 0;
+
+    utils::BasicRenderPass rp = utils::CreateBasicRenderPass(device, 1, 1);
+    rp.renderPassInfo.nextInChain = &rpPlsDesc;
+
+    wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+    wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&rp.renderPassInfo);
+    // Calling PixelLocalStorageBarrier is allowed.
+    pass.PixelLocalStorageBarrier();
+    pass.End();
+    encoder.Finish();
+}
+
+struct OffsetAndFormat {
+    uint64_t offset;
+    wgpu::TextureFormat format;
+};
+struct PLSSpec {
+    uint64_t totalSize;
+    std::vector<OffsetAndFormat> attachments;
+    bool active = true;
+};
+
+constexpr std::array<wgpu::TextureFormat, 3> kStorageAttachmentFormats = {
+    wgpu::TextureFormat::R32Float,
+    wgpu::TextureFormat::R32Uint,
+    wgpu::TextureFormat::R32Sint,
+};
+bool IsStorageAttachmentFormat(wgpu::TextureFormat format) {
+    return std::find(kStorageAttachmentFormats.begin(), kStorageAttachmentFormats.end(), format) !=
+           kStorageAttachmentFormats.end();
+}
+
+struct ComboTestPLSRenderPassDescriptor : NonMovable {
+    std::array<wgpu::RenderPassStorageAttachment, 8> storageAttachments;
+    wgpu::RenderPassPixelLocalStorage pls;
+    wgpu::RenderPassColorAttachment colorAttachment;
+    wgpu::RenderPassDescriptor rpDesc;
+};
+
+class PixelLocalStorageTest : public ValidationTest {
+  protected:
+    WGPUDevice CreateTestDevice(native::Adapter dawnAdapter,
+                                wgpu::DeviceDescriptor descriptor) override {
+        // Test only the non-coherent version, and assume that the same validation code paths are
+        // taken for the coherent path.
+        wgpu::FeatureName requiredFeatures[1] = {wgpu::FeatureName::PixelLocalStorageNonCoherent};
+        descriptor.requiredFeatures = requiredFeatures;
+        descriptor.requiredFeatureCount = 1;
+        return dawnAdapter.CreateDevice(&descriptor);
+    }
+
+    void InitializePLSRenderPass(ComboTestPLSRenderPassDescriptor* desc) {
+        // Set up a single storage attachment.
+        wgpu::TextureDescriptor storageDesc;
+        storageDesc.size = {1, 1};
+        storageDesc.format = wgpu::TextureFormat::R32Uint;
+        storageDesc.usage = wgpu::TextureUsage::StorageAttachment;
+        wgpu::Texture storage = device.CreateTexture(&storageDesc);
+
+        desc->storageAttachments[0].storage = storage.CreateView();
+        desc->storageAttachments[0].offset = 0;
+        desc->storageAttachments[0].loadOp = wgpu::LoadOp::Load;
+        desc->storageAttachments[0].storeOp = wgpu::StoreOp::Store;
+
+        desc->pls.totalPixelLocalStorageSize = 4;
+        desc->pls.storageAttachmentCount = 1;
+        desc->pls.storageAttachments = desc->storageAttachments.data();
+
+        // Add at least one color attachment to make the render pass valid if there's no storage
+        // attachment.
+        wgpu::TextureDescriptor colorDesc;
+        colorDesc.size = {1, 1};
+        colorDesc.format = kColorAttachmentFormat;
+        colorDesc.usage = wgpu::TextureUsage::RenderAttachment;
+        wgpu::Texture color = device.CreateTexture(&colorDesc);
+
+        desc->colorAttachment.view = color.CreateView();
+        desc->colorAttachment.loadOp = wgpu::LoadOp::Load;
+        desc->colorAttachment.storeOp = wgpu::StoreOp::Store;
+
+        desc->rpDesc.nextInChain = &desc->pls;
+        desc->rpDesc.colorAttachmentCount = 1;
+        desc->rpDesc.colorAttachments = &desc->colorAttachment;
+    }
+
+    void RecordRenderPass(const wgpu::RenderPassDescriptor* desc,
+                          wgpu::RenderPipeline pipeline = {}) {
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(desc);
+
+        if (pipeline) {
+            pass.SetPipeline(pipeline);
+        }
+
+        pass.End();
+        encoder.Finish();
+    }
+
+    void RecordPLSRenderPass(const PLSSpec& spec, wgpu::RenderPipeline pipeline = {}) {
+        ComboTestPLSRenderPassDescriptor desc;
+        InitializePLSRenderPass(&desc);
+
+        // Convert the PLSSpec to a RenderPassPLS
+        for (size_t i = 0; i < spec.attachments.size(); i++) {
+            wgpu::TextureDescriptor tDesc;
+            tDesc.size = {1, 1};
+            tDesc.format = spec.attachments[i].format;
+            tDesc.usage = wgpu::TextureUsage::StorageAttachment;
+            wgpu::Texture texture = device.CreateTexture(&tDesc);
+
+            desc.storageAttachments[i].storage = texture.CreateView();
+            desc.storageAttachments[i].offset = spec.attachments[i].offset;
+            desc.storageAttachments[i].loadOp = wgpu::LoadOp::Load;
+            desc.storageAttachments[i].storeOp = wgpu::StoreOp::Store;
+        }
+
+        desc.pls.totalPixelLocalStorageSize = spec.totalSize;
+        desc.pls.storageAttachmentCount = spec.attachments.size();
+
+        // Add the PLS if needed and record the render pass.
+        if (!spec.active) {
+            desc.rpDesc.nextInChain = nullptr;
+        }
+
+        RecordRenderPass(&desc.rpDesc, pipeline);
+    }
+
+    wgpu::PipelineLayout MakePipelineLayout(const PLSSpec& spec) {
+        // Convert the PLSSpec to a PipelineLayoutPLS
+        std::vector<wgpu::PipelineLayoutStorageAttachment> storageAttachments;
+        for (const auto& attachmentSpec : spec.attachments) {
+            wgpu::PipelineLayoutStorageAttachment attachment;
+            attachment.format = attachmentSpec.format;
+            attachment.offset = attachmentSpec.offset;
+            storageAttachments.push_back(attachment);
+        }
+
+        wgpu::PipelineLayoutPixelLocalStorage pls;
+        pls.totalPixelLocalStorageSize = spec.totalSize;
+        pls.storageAttachmentCount = storageAttachments.size();
+        pls.storageAttachments = storageAttachments.data();
+
+        // Add the PLS if needed and make the pipeline layout.
+        wgpu::PipelineLayoutDescriptor plDesc;
+        plDesc.bindGroupLayoutCount = 0;
+        if (spec.active) {
+            plDesc.nextInChain = &pls;
+        }
+        return device.CreatePipelineLayout(&plDesc);
+    }
+
+    wgpu::RenderPipeline MakePipeline(const PLSSpec& spec) {
+        utils::ComboRenderPipelineDescriptor desc;
+        wgpu::ShaderModule module = utils::CreateShaderModule(device, R"(
+            @vertex fn vs() -> @builtin(position) vec4f {
+                return vec4f();
+            }
+            @fragment fn fs() -> @location(0) u32 {
+                return 0u;
+            }
+        )");
+
+        desc.layout = MakePipelineLayout(spec);
+        desc.cFragment.module = module;
+        desc.cFragment.entryPoint = "fs";
+        desc.vertex.module = module;
+        desc.vertex.entryPoint = "vs";
+        desc.cTargets[0].format = kColorAttachmentFormat;
+        return device.CreateRenderPipeline(&desc);
+    }
+
+    void CheckPLSStateMatching(const PLSSpec& rpSpec, const PLSSpec& pipelineSpec, bool success) {
+        wgpu::RenderPipeline pipeline = MakePipeline(pipelineSpec);
+
+        if (success) {
+            RecordPLSRenderPass(rpSpec, pipeline);
+        } else {
+            ASSERT_DEVICE_ERROR(RecordPLSRenderPass(rpSpec, pipeline));
+        }
+    }
+
+    static constexpr wgpu::TextureFormat kColorAttachmentFormat = wgpu::TextureFormat::R32Uint;
+};
+
+// Check that StorageAttachment textures must be one of the supported formats.
+TEST_F(PixelLocalStorageTest, TextureFormatMustSupportStorageAttachment) {
+    for (wgpu::TextureFormat format : utils::kAllTextureFormats) {
+        wgpu::TextureDescriptor desc;
+        desc.size = {1, 1};
+        desc.format = format;
+        desc.usage = wgpu::TextureUsage::StorageAttachment;
+
+        if (IsStorageAttachmentFormat(format)) {
+            device.CreateTexture(&desc);
+        } else {
+            ASSERT_DEVICE_ERROR(device.CreateTexture(&desc));
+        }
+    }
+}
+
+// Check that StorageAttachment textures must have a sample count of 1.
+TEST_F(PixelLocalStorageTest, TextureMustBeSingleSampled) {
+    wgpu::TextureDescriptor desc;
+    desc.size = {1, 1};
+    desc.format = wgpu::TextureFormat::R32Uint;
+    desc.usage = wgpu::TextureUsage::StorageAttachment;
+
+    // Control case: sampleCount = 1 is valid.
+    desc.sampleCount = 1;
+    device.CreateTexture(&desc);
+
+    // Error case: sampledCount != 1 is an error.
+    desc.sampleCount = 4;
+    ASSERT_DEVICE_ERROR(device.CreateTexture(&desc));
+}
+
+// Check that the format in PLS must be one of the enabled ones.
+TEST_F(PixelLocalStorageTest, PLSStateFormatMustSupportStorageAttachment) {
+    for (wgpu::TextureFormat format : utils::kFormatsInCoreSpec) {
+        PLSSpec spec = {4, {{0, format}}};
+
+        // Note that BeginRenderPass is not tested here as a different test checks that the
+        // StorageAttachment texture must indeed have been created with the StorageAttachment usage.
+        if (IsStorageAttachmentFormat(format)) {
+            MakePipelineLayout(spec);
+        } else {
+            ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        }
+    }
+}
+
+// Check that the total size must be a multiple of 4.
+TEST_F(PixelLocalStorageTest, PLSStateTotalSizeMultipleOf4) {
+    // Control case: total size is a multiple of 4.
+    {
+        PLSSpec spec = {4, {}};
+        MakePipelineLayout(spec);
+        RecordPLSRenderPass(spec);
+    }
+
+    // Control case: total size isn't a multiple of 4.
+    {
+        PLSSpec spec = {2, {}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+}
+
+// Check that the total size must be less than 16.
+// TODO(dawn:1704): Have a proper limit for totalSize.
+TEST_F(PixelLocalStorageTest, PLSStateTotalLessThan16) {
+    // Control case: total size is 16.
+    {
+        PLSSpec spec = {16, {}};
+        MakePipelineLayout(spec);
+        RecordPLSRenderPass(spec);
+    }
+
+    // Control case: total size is greater than 16.
+    {
+        PLSSpec spec = {20, {}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+}
+
+// Check that the offset of a storage attachment must be a multiple of 4.
+TEST_F(PixelLocalStorageTest, PLSStateOffsetMultipleOf4) {
+    // Control case: offset is a multiple of 4.
+    {
+        PLSSpec spec = {8, {{4, wgpu::TextureFormat::R32Uint}}};
+        MakePipelineLayout(spec);
+        RecordPLSRenderPass(spec);
+    }
+
+    // Error case: offset isn't a multiple of 4.
+    {
+        PLSSpec spec = {8, {{2, wgpu::TextureFormat::R32Uint}}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+}
+
+// Check that the storage attachment is in bounds of the total size.
+TEST_F(PixelLocalStorageTest, PLSStateAttachmentInBoundsOfTotalSize) {
+    // Note that all storage attachment formats are currently 4 byte wide.
+
+    // Control case: 0 + 4 <= 4
+    {
+        PLSSpec spec = {4, {{0, wgpu::TextureFormat::R32Uint}}};
+        MakePipelineLayout(spec);
+        RecordPLSRenderPass(spec);
+    }
+
+    // Error case: 4 + 4 > 4
+    {
+        PLSSpec spec = {4, {{4, wgpu::TextureFormat::R32Uint}}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+
+    // Control case: 8 + 4 <= 12
+    {
+        PLSSpec spec = {12, {{8, wgpu::TextureFormat::R32Uint}}};
+        MakePipelineLayout(spec);
+        RecordPLSRenderPass(spec);
+    }
+
+    // Error case: 12 + 4 > 12
+    {
+        PLSSpec spec = {4, {{12, wgpu::TextureFormat::R32Uint}}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+
+    // Check that overflows don't incorrectly pass the validation.
+    {
+        PLSSpec spec = {4, {{uint64_t(0) - uint64_t(4), wgpu::TextureFormat::R32Uint}}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+}
+
+// Check that collisions between storage attachments are not allowed.
+TEST_F(PixelLocalStorageTest, PLSStateCollisionsDisallowed) {
+    // Control case: no collisions, all is good!
+    {
+        PLSSpec spec = {8, {{0, wgpu::TextureFormat::R32Uint}, {4, wgpu::TextureFormat::R32Uint}}};
+        MakePipelineLayout(spec);
+        RecordPLSRenderPass(spec);
+    }
+    // Error case: collisions, boo!
+    {
+        PLSSpec spec = {8, {{0, wgpu::TextureFormat::R32Uint}, {0, wgpu::TextureFormat::R32Uint}}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+    {
+        PLSSpec spec = {8,
+                        {{0, wgpu::TextureFormat::R32Uint},
+                         {4, wgpu::TextureFormat::R32Uint},
+                         {0, wgpu::TextureFormat::R32Uint}}};
+        ASSERT_DEVICE_ERROR(MakePipelineLayout(spec));
+        ASSERT_DEVICE_ERROR(RecordPLSRenderPass(spec));
+    }
+}
+
+// Check that using an error view as storage attachment is an error.
+TEST_F(PixelLocalStorageTest, RenderPassStorageAttachmentErrorView) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {1, 1};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment;
+    tDesc.format = wgpu::TextureFormat::R32Uint;
+    wgpu::Texture t = device.CreateTexture(&tDesc);
+
+    wgpu::TextureViewDescriptor viewDesc;
+
+    // Control case: valid texture view.
+    desc.storageAttachments[0].storage = t.CreateView(&viewDesc);
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: invalid texture view because of the base array layer.
+    viewDesc.baseArrayLayer = 10;
+    ASSERT_DEVICE_ERROR(desc.storageAttachments[0].storage = t.CreateView(&viewDesc));
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that using a multi-subresource view as a storage attachment is an error (layers and levels
+// cases).
+TEST_F(PixelLocalStorageTest, RenderPassStorageAttachmentSingleSubresource) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    wgpu::TextureDescriptor colorDesc;
+    colorDesc.size = {2, 2};
+    colorDesc.usage = wgpu::TextureUsage::RenderAttachment;
+    colorDesc.format = kColorAttachmentFormat;
+
+    // Replace the render pass attachment with a 2x2 texture for mip level testing.
+    desc.colorAttachment.view = device.CreateTexture(&colorDesc).CreateView();
+
+    // Control case: single subresource view.
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {2, 2};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment;
+    tDesc.format = wgpu::TextureFormat::R32Uint;
+
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: two array layers.
+    tDesc.size.depthOrArrayLayers = 2;
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+
+    // Error case: two mip levels.
+    tDesc.size.depthOrArrayLayers = 1;
+    tDesc.mipLevelCount = 2;
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that the size of storage attachments must match the size of other attachments.
+TEST_F(PixelLocalStorageTest, RenderPassStorageAttachmentSizeMustMatch) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Explicitly set the color attachment to a 1x1 texture.
+    wgpu::TextureDescriptor colorDesc;
+    colorDesc.size = {1, 1};
+    colorDesc.usage = wgpu::TextureUsage::RenderAttachment;
+    colorDesc.format = kColorAttachmentFormat;
+    desc.colorAttachment.view = device.CreateTexture(&colorDesc).CreateView();
+
+    // Control case: the storage attachment size matches
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {1, 1};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment;
+    tDesc.format = wgpu::TextureFormat::R32Uint;
+
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: width doesn't match.
+    tDesc.size = {2, 1};
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+
+    // Error case: height doesn't match.
+    tDesc.size = {1, 2};
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that the textures used as storage attachment must have the StorageAttachment TextureUsage.
+TEST_F(PixelLocalStorageTest, RenderPassStorageAttachmentUsage) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Control case: the storage attachment has the correct usage.
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {1, 1};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment;
+    tDesc.format = wgpu::TextureFormat::R32Uint;
+
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: the storage attachment doesn't have the usage.
+    tDesc.usage = wgpu::TextureUsage::RenderAttachment;
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that the same texture subresource cannot be used twice as a storage attachment.
+TEST_F(PixelLocalStorageTest, RenderPassSubresourceUsedTwiceAsStorage) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Control case: two different subresources for two storage attachments.
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {1, 1};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment;
+    tDesc.format = wgpu::TextureFormat::R32Uint;
+
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+
+    desc.storageAttachments[1].storage = device.CreateTexture(&tDesc).CreateView();
+    desc.storageAttachments[1].offset = 4;
+    desc.storageAttachments[1].loadOp = wgpu::LoadOp::Load;
+    desc.storageAttachments[1].storeOp = wgpu::StoreOp::Store;
+    desc.pls.storageAttachmentCount = 2;
+    desc.pls.totalPixelLocalStorageSize = 8;
+
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: the same subresource is used twice as a storage attachment.
+    desc.storageAttachments[0].storage = desc.storageAttachments[1].storage;
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that the same texture subresource cannot be used twice as a storage and render attachment.
+TEST_F(PixelLocalStorageTest, RenderPassSubresourceUsedAsStorageAndRender) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Control case: two different subresources for storage and render attachments.
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {1, 1};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment | wgpu::TextureUsage::RenderAttachment;
+    tDesc.format = kColorAttachmentFormat;
+
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: the same view is used twice, once as storage, once as render attachment.
+    desc.colorAttachment.view = desc.storageAttachments[0].storage;
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that using a subresource as storage attachment prevents other usages in the render pass.
+TEST_F(PixelLocalStorageTest, RenderPassSubresourceUsedInsidePass) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    wgpu::TextureDescriptor tDesc;
+    tDesc.size = {1, 1};
+    tDesc.usage = wgpu::TextureUsage::StorageAttachment | wgpu::TextureUsage::TextureBinding;
+    tDesc.format = wgpu::TextureFormat::R32Uint;
+
+    desc.storageAttachments[0].storage = device.CreateTexture(&tDesc).CreateView();
+
+    // Control case: the storage attachment is used only as storage attachment.
+    {
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&desc.rpDesc);
+        pass.End();
+        encoder.Finish();
+    }
+
+    // Error case: the storage attachment is also used as a texture binding in a bind group.
+    {
+        wgpu::BindGroupLayout bgl = utils::MakeBindGroupLayout(
+            device, {{0, wgpu::ShaderStage::Fragment, wgpu::TextureSampleType::Uint}});
+        wgpu::BindGroup bg =
+            utils::MakeBindGroup(device, bgl, {{0, desc.storageAttachments[0].storage}});
+
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&desc.rpDesc);
+        pass.SetBindGroup(0, bg);
+        pass.End();
+        ASSERT_DEVICE_ERROR(encoder.Finish());
+    }
+}
+
+// Check that the load and store op must not be undefined.
+TEST_F(PixelLocalStorageTest, RenderPassLoadAndStoreOpNotUndefined) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Control case: ops are not undefined.
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: LoadOp::Undefined
+    desc.storageAttachments[0].loadOp = wgpu::LoadOp::Undefined;
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+    desc.storageAttachments[0].loadOp = wgpu::LoadOp::Load;
+
+    // Error case: StoreOp::Undefined
+    desc.storageAttachments[0].storeOp = wgpu::StoreOp::Undefined;
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that the clear value, if used, must not have NaNs.
+TEST_F(PixelLocalStorageTest, RenderPassClearValueNaNs) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    const float kNaN = std::nan("");
+
+    // Check that NaNs are allowed if the loadOp is not Clear.
+    desc.storageAttachments[0].loadOp = wgpu::LoadOp::Load;
+    desc.storageAttachments[0].clearValue = {kNaN, kNaN, kNaN, kNaN};
+    RecordRenderPass(&desc.rpDesc);
+
+    // Control case, a non-NaN clear value is allowed.
+    desc.storageAttachments[0].loadOp = wgpu::LoadOp::Clear;
+    desc.storageAttachments[0].clearValue = {0, 0, 0, 0};
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: NaN in one of the components of clearValue.
+    desc.storageAttachments[0].clearValue = {kNaN, 0, 0, 0};
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+
+    desc.storageAttachments[0].clearValue = {0, kNaN, 0, 0};
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+
+    desc.storageAttachments[0].clearValue = {0, 0, kNaN, 0};
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+
+    desc.storageAttachments[0].clearValue = {0, 0, 0, kNaN};
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// Check that using a subresource as storage attachment prevents other usages in the render pass.
+TEST_F(PixelLocalStorageTest, PixelLocalStorageBarrierRequiresPLS) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Control case: there is a PLS, the barrier is allowed.
+    {
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&desc.rpDesc);
+        pass.PixelLocalStorageBarrier();
+        pass.End();
+        encoder.Finish();
+    }
+
+    // Error case: there is no PLS (it is unlinked from chained structs), the barrier is not
+    // allowed.
+    {
+        desc.rpDesc.nextInChain = nullptr;
+
+        wgpu::CommandEncoder encoder = device.CreateCommandEncoder();
+        wgpu::RenderPassEncoder pass = encoder.BeginRenderPass(&desc.rpDesc);
+        pass.PixelLocalStorageBarrier();
+        pass.End();
+        ASSERT_DEVICE_ERROR(encoder.Finish());
+    }
+}
+
+// Check that PLS state differs between no PLS and empty PLS
+TEST_F(PixelLocalStorageTest, PLSStateMatching_EmptyPLSVsNoPLS) {
+    PLSSpec emptyPLS = {0, {}, true};
+    PLSSpec noPLS = {0, {}, false};
+
+    CheckPLSStateMatching(emptyPLS, emptyPLS, true);
+    CheckPLSStateMatching(noPLS, noPLS, true);
+    CheckPLSStateMatching(emptyPLS, noPLS, false);
+    CheckPLSStateMatching(noPLS, emptyPLS, false);
+}
+
+// Check that PLS state differs between empty PLS and non-empty PLS with no storage attachments
+TEST_F(PixelLocalStorageTest, PLSStateMatching_EmptyPLSVsNotEmpty) {
+    PLSSpec emptyPLS = {0, {}};
+    PLSSpec notEmptyPLS = {4, {}};
+
+    CheckPLSStateMatching(emptyPLS, emptyPLS, true);
+    CheckPLSStateMatching(notEmptyPLS, notEmptyPLS, true);
+    CheckPLSStateMatching(emptyPLS, notEmptyPLS, false);
+    CheckPLSStateMatching(notEmptyPLS, emptyPLS, false);
+}
+
+// Check that PLS state differs between implicit PLS vs storage attachment
+TEST_F(PixelLocalStorageTest, PLSStateMatching_AttachmentVsImplicit) {
+    PLSSpec implicitPLS = {4, {}};
+    PLSSpec storagePLS = {4, {{0, wgpu::TextureFormat::R32Uint}}};
+
+    CheckPLSStateMatching(implicitPLS, implicitPLS, true);
+    CheckPLSStateMatching(storagePLS, storagePLS, true);
+    CheckPLSStateMatching(implicitPLS, storagePLS, false);
+    CheckPLSStateMatching(storagePLS, implicitPLS, false);
+}
+
+// Check that PLS state differs between storage attachment formats
+TEST_F(PixelLocalStorageTest, PLSStateMatching_Format) {
+    PLSSpec intPLS = {4, {{0, wgpu::TextureFormat::R32Sint}}};
+    PLSSpec uintPLS = {4, {{0, wgpu::TextureFormat::R32Uint}}};
+
+    CheckPLSStateMatching(intPLS, intPLS, true);
+    CheckPLSStateMatching(uintPLS, uintPLS, true);
+    CheckPLSStateMatching(intPLS, uintPLS, false);
+    CheckPLSStateMatching(uintPLS, intPLS, false);
+}
+
+// Check that PLS state are equal even if attachments are specified in different orders
+TEST_F(PixelLocalStorageTest, PLSStateMatching_StorageAttachmentOrder) {
+    PLSSpec pls1 = {8, {{4, wgpu::TextureFormat::R32Uint}, {0, wgpu::TextureFormat::R32Sint}}};
+    PLSSpec pls2 = {8, {{0, wgpu::TextureFormat::R32Sint}, {4, wgpu::TextureFormat::R32Uint}}};
+
+    CheckPLSStateMatching(pls1, pls2, true);
+}
+
+class PixelLocalStorageAndRenderToSingleSampledTest : public PixelLocalStorageTest {
+  protected:
+    WGPUDevice CreateTestDevice(native::Adapter dawnAdapter,
+                                wgpu::DeviceDescriptor descriptor) override {
+        // TODO(dawn:1704): Do we need to test both extensions?
+        wgpu::FeatureName requiredFeatures[2] = {wgpu::FeatureName::PixelLocalStorageNonCoherent,
+                                                 wgpu::FeatureName::MSAARenderToSingleSampled};
+        descriptor.requiredFeatures = requiredFeatures;
+        descriptor.requiredFeatureCount = 2;
+        return dawnAdapter.CreateDevice(&descriptor);
+    }
+};
+
+// Check that PLS + MSAA render to single sampled is not allowed
+TEST_F(PixelLocalStorageAndRenderToSingleSampledTest, CombinationIsNotAllowed) {
+    ComboTestPLSRenderPassDescriptor desc;
+    InitializePLSRenderPass(&desc);
+
+    // Control case: no MSAA render to single sampled.
+    RecordRenderPass(&desc.rpDesc);
+
+    // Error case: MSAA render to single sampled is added to the color attachment.
+    wgpu::DawnRenderPassColorAttachmentRenderToSingleSampled msaaRenderToSingleSampledDesc;
+    msaaRenderToSingleSampledDesc.implicitSampleCount = 4;
+    desc.colorAttachment.nextInChain = &msaaRenderToSingleSampledDesc;
+    ASSERT_DEVICE_ERROR(RecordRenderPass(&desc.rpDesc));
+}
+
+// TODO(dawn:1704): Add tests for limits
+
 }  // anonymous namespace
 }  // namespace dawn