spirv-reader: ignore storing 1.0 to PointSize builtin

If you try to load it, return 1.0f instead.
Some cases of copy-object of intermediates are unhandled,
and will error out.

This is being done as an aid to porting GLSL Vulkan shaders
that do store 1 to gl_PointSize.

Fixed: tint:412
Change-Id: Ia33dc70bca630dccfbf11644f71d6be4b3f43f1a
Reviewed-on: https://dawn-review.googlesource.com/c/tint/+/35861
Commit-Queue: dan sinclair <dsinclair@chromium.org>
Reviewed-by: dan sinclair <dsinclair@chromium.org>
Auto-Submit: David Neto <dneto@google.com>
diff --git a/src/reader/spirv/function.cc b/src/reader/spirv/function.cc
index 0e7db13..83ac797 100644
--- a/src/reader/spirv/function.cc
+++ b/src/reader/spirv/function.cc
@@ -41,6 +41,7 @@
 #include "src/ast/discard_statement.h"
 #include "src/ast/else_statement.h"
 #include "src/ast/fallthrough_statement.h"
+#include "src/ast/float_literal.h"
 #include "src/ast/identifier_expression.h"
 #include "src/ast/if_statement.h"
 #include "src/ast/intrinsic.h"
@@ -1971,6 +1972,24 @@
   if (failed()) {
     return {};
   }
+  switch (GetSkipReason(id)) {
+    case SkipReason::kDontSkip:
+      break;
+    case SkipReason::kOpaqueObject:
+      Fail() << "internal error: unhandled use of opaque object with ID: "
+             << id;
+      return {};
+    case SkipReason::kPointSizeBuiltinValue: {
+      auto* f32 = create<ast::type::F32>();
+      return {f32,
+              create<ast::ScalarConstructorExpression>(
+                  Source{}, create<ast::FloatLiteral>(Source{}, f32, 1.0f))};
+    }
+    case SkipReason::kPointSizeBuiltinPointer:
+      Fail() << "unhandled use of a pointer to the PointSize builtin, with ID: "
+             << id;
+      return {};
+  }
   if (identifier_values_.count(id) || parser_impl_.IsScalarSpecConstant(id)) {
     auto name = namer_.Name(id);
     return TypedExpression{
@@ -2848,21 +2867,31 @@
 
   if (type_id != 0) {
     const auto& builtin_position_info = parser_impl_.GetBuiltInPositionInfo();
-    if ((type_id == builtin_position_info.struct_type_id) ||
-        (type_id == builtin_position_info.pointer_type_id)) {
+    if (type_id == builtin_position_info.struct_type_id) {
       return Fail() << "operations producing a per-vertex structure are not "
                        "supported: "
                     << inst.PrettyPrint();
     }
+    if (type_id == builtin_position_info.pointer_type_id) {
+      return Fail() << "operations producing a pointer to a per-vertex "
+                       "structure are not "
+                       "supported: "
+                    << inst.PrettyPrint();
+    }
   }
 
   // Handle combinatorial instructions.
   const auto* def_info = GetDefInfo(result_id);
   if (def_info) {
+    TypedExpression combinatorial_expr;
+    if (def_info->skip == SkipReason::kDontSkip) {
+      combinatorial_expr = MaybeEmitCombinatorialValue(inst);
+    }
+    // An access chain or OpCopyObject can generate a skip.
     if (def_info->skip != SkipReason::kDontSkip) {
       return true;
     }
-    auto combinatorial_expr = MaybeEmitCombinatorialValue(inst);
+
     if (combinatorial_expr.expr != nullptr) {
       if (def_info->requires_hoisted_def ||
           def_info->requires_named_const_def || def_info->num_uses != 1) {
@@ -2892,6 +2921,23 @@
     case SpvOpStore: {
       const auto ptr_id = inst.GetSingleWordInOperand(0);
       const auto value_id = inst.GetSingleWordInOperand(1);
+
+      // Handle exceptional cases
+      if (GetSkipReason(ptr_id) == SkipReason::kPointSizeBuiltinPointer) {
+        if (const auto* c = constant_mgr_->FindDeclaredConstant(value_id)) {
+          // If we're writing a constant 1.0, then skip the write.  That's all
+          // that WebGPU handles.
+          auto* ct = c->type();
+          if (ct->AsFloat() && (ct->AsFloat()->width() == 32) &&
+              (c->GetFloat() == 1.0f)) {
+            // Don't store to PointSize
+            return true;
+          }
+        }
+        return Fail() << "cannot store a value other than constant 1.0 to "
+                         "PointSize builtin: "
+                      << inst.PrettyPrint();
+      }
       const auto ptr_type_id = def_use_mgr_->GetDef(ptr_id)->type_id();
       const auto& builtin_position_info = parser_impl_.GetBuiltInPositionInfo();
       if (ptr_type_id == builtin_position_info.pointer_type_id) {
@@ -2900,6 +2946,7 @@
                << inst.PrettyPrint();
       }
 
+      // Handle an ordinary store as an assignment.
       // TODO(dneto): Order of evaluation?
       auto lhs = MakeExpression(ptr_id);
       auto rhs = MakeExpression(value_id);
@@ -2907,23 +2954,42 @@
           create<ast::AssignmentStatement>(Source{}, lhs.expr, rhs.expr));
       return success();
     }
+
     case SpvOpLoad: {
       // Memory accesses must be issued in SPIR-V program order.
       // So represent a load by a new const definition.
-      auto expr = MakeExpression(inst.GetSingleWordInOperand(0));
+      const auto ptr_id = inst.GetSingleWordInOperand(0);
+      if (GetSkipReason(ptr_id) == SkipReason::kPointSizeBuiltinPointer) {
+        GetDefInfo(inst.result_id())->skip = SkipReason::kPointSizeBuiltinValue;
+        return true;
+      }
+      auto expr = MakeExpression(ptr_id);
       // The load result type is the pointee type of its operand.
       assert(expr.type->Is<ast::type::Pointer>());
       expr.type = expr.type->As<ast::type::Pointer>()->type();
       return EmitConstDefOrWriteToHoistedVar(inst, expr);
     }
+
     case SpvOpCopyObject: {
       // Arguably, OpCopyObject is purely combinatorial. On the other hand,
       // it exists to make a new name for something. So we choose to make
       // a new named constant definition.
-      auto expr = MakeExpression(inst.GetSingleWordInOperand(0));
+      auto value_id = inst.GetSingleWordInOperand(0);
+      const auto skip = GetSkipReason(value_id);
+      switch (skip) {
+        case SkipReason::kDontSkip:
+          break;
+        case SkipReason::kOpaqueObject:
+        case SkipReason::kPointSizeBuiltinPointer:
+        case SkipReason::kPointSizeBuiltinValue:
+          GetDefInfo(inst.result_id())->skip = skip;
+          return true;
+      }
+      auto expr = MakeExpression(value_id);
       expr.type = RemapStorageClass(expr.type, result_id);
       return EmitConstDefOrWriteToHoistedVar(inst, expr);
     }
+
     case SpvOpPhi: {
       // Emit a read from the associated state variable.
       TypedExpression expr{
@@ -2933,8 +2999,10 @@
               def_info->phi_var)};
       return EmitConstDefOrWriteToHoistedVar(inst, expr);
     }
+
     case SpvOpFunctionCall:
       return EmitFunctionCall(inst);
+
     default:
       break;
   }
@@ -3159,6 +3227,12 @@
 
   // If the variable was originally gl_PerVertex, then in the AST we
   // have instead emitted a gl_Position variable.
+  // If computing the pointer to the Position builtin, then emit the
+  // pointer to the generated gl_Position variable.
+  // If computing the pointer to the PointSize builtin, then mark the
+  // result as skippable due to being the point-size pointer.
+  // If computing the pointer to the ClipDistance or CullDistance builtins,
+  // then error out.
   {
     const auto& builtin_position_info = parser_impl_.GetBuiltInPositionInfo();
     if (base_id == builtin_position_info.per_vertex_var_id) {
@@ -3188,16 +3262,26 @@
       }
       const auto member_index_value =
           member_index_const_int->GetZeroExtendedValue();
-      if (member_index_value != builtin_position_info.member_index) {
-        Fail() << "accessing per-vertex member " << member_index_value
-               << " is not supported. Only Position is supported";
-        return {};
+      if (member_index_value != builtin_position_info.position_member_index) {
+        if (member_index_value ==
+            builtin_position_info.pointsize_member_index) {
+          if (auto* def_info = GetDefInfo(inst.result_id())) {
+            def_info->skip = SkipReason::kPointSizeBuiltinPointer;
+            return {};
+          }
+        } else {
+          // TODO(dneto): Handle ClipDistance and CullDistance
+          Fail() << "accessing per-vertex member " << member_index_value
+                 << " is not supported. Only Position is supported, and "
+                    "PointSize is ignored";
+          return {};
+        }
       }
 
       // Skip past the member index that gets us to Position.
       first_index = first_index + 1;
       // Replace the gl_PerVertex reference with the gl_Position reference
-      ptr_ty_id = builtin_position_info.member_pointer_type_id;
+      ptr_ty_id = builtin_position_info.position_member_pointer_type_id;
 
       auto name = namer_.Name(base_id);
       current_expr.expr = create<ast::IdentifierExpression>(
diff --git a/src/reader/spirv/function.h b/src/reader/spirv/function.h
index 29f4076..c82fa91 100644
--- a/src/reader/spirv/function.h
+++ b/src/reader/spirv/function.h
@@ -213,11 +213,14 @@
   /// function parameter).
   kOpaqueObject,
 
-  /// `kPointSizeBuiltin`: the value is a pointer to the Position builtin
-  /// variable.  Don't generate its address.  Avoid generating stores to
-  /// this pointer.  When loading from the pointer, yield the value 1,
-  /// the only supported value for PointSize.
-  kPointSizeBuiltin
+  /// `kPointSizeBuiltinPointer`: the value is a pointer to the Position builtin
+  /// variable.  Don't generate its address.  Avoid generating stores to this
+  /// pointer.
+  kPointSizeBuiltinPointer,
+  /// `kPointSizeBuiltinValue`: the value is the value loaded from the
+  /// PointSize builtin. Use 1.0f instead, because that's the only value
+  /// supported by WebGPU.
+  kPointSizeBuiltinValue,
 };
 
 /// Bookkeeping info for a SPIR-V ID defined in the function.
@@ -287,6 +290,10 @@
   /// This is kNone for non-pointers.
   ast::StorageClass storage_class = ast::StorageClass::kNone;
 
+  /// The reason, if any, that this value should not be generated.
+  /// Normally all values are generated.  This field can be updated while
+  /// generating code because sometimes we only discover necessary facts
+  /// in the middle of generating code.
   SkipReason skip = SkipReason::kDontSkip;
 };
 
@@ -307,8 +314,11 @@
     case SkipReason::kOpaqueObject:
       o << " skip:opaque";
       break;
-    case SkipReason::kPointSizeBuiltin:
-      o << " skip:pointsize";
+    case SkipReason::kPointSizeBuiltinPointer:
+      o << " skip:pointsize_pointer";
+      break;
+    case SkipReason::kPointSizeBuiltinValue:
+      o << " skip:pointsize_value";
       break;
   }
   o << "}";
@@ -696,6 +706,15 @@
     }
     return where->second.get();
   }
+  /// Returns the skip reason for a result ID.
+  /// @param id SPIR-V result ID
+  /// @returns the skip reason for the given ID, or SkipReason::kDontSkip
+  SkipReason GetSkipReason(uint32_t id) const {
+    if (auto* def_info = GetDefInfo(id)) {
+      return def_info->skip;
+    }
+    return SkipReason::kDontSkip;
+  }
 
   /// Returns the most deeply nested structured construct which encloses the
   /// WGSL scopes of names declared in both block positions. Each position must
diff --git a/src/reader/spirv/parser_impl.cc b/src/reader/spirv/parser_impl.cc
index b9ee3c6..4ebc642 100644
--- a/src/reader/spirv/parser_impl.cc
+++ b/src/reader/spirv/parser_impl.cc
@@ -886,6 +886,7 @@
   ast::StructMemberList ast_members;
   const auto members = struct_ty->element_types();
   unsigned num_non_writable_members = 0;
+  bool is_per_vertex_struct = false;
   for (uint32_t member_index = 0; member_index < members.size();
        ++member_index) {
     const auto member_type_id = type_mgr_->GetId(members[member_index]);
@@ -906,18 +907,24 @@
           case SpvBuiltInPosition:
             // Record this built-in variable specially.
             builtin_position_.struct_type_id = type_id;
-            builtin_position_.member_index = member_index;
-            builtin_position_.member_type_id = member_type_id;
+            builtin_position_.position_member_index = member_index;
+            builtin_position_.position_member_type_id = member_type_id;
             // Don't map the struct type.  But this is not an error either.
-            return nullptr;
-          case SpvBuiltInPointSize:     // not supported in WGSL
-          case SpvBuiltInCullDistance:  // not supported in WGSL
-          case SpvBuiltInClipDistance:  // not supported in WGSL
-          default:
+            is_per_vertex_struct = true;
             break;
+          case SpvBuiltInPointSize:  // not supported in WGSL, but ignore
+            builtin_position_.pointsize_member_index = member_index;
+            is_per_vertex_struct = true;
+            break;
+          case SpvBuiltInClipDistance:  // not supported in WGSL
+          case SpvBuiltInCullDistance:  // not supported in WGSL
+            // Silently ignore, so we can detect Position and PointSize
+            is_per_vertex_struct = true;
+            break;
+          default:
+            Fail() << "unrecognized builtin " << decoration[1];
+            return nullptr;
         }
-        Fail() << "unrecognized builtin " << decoration[1];
-        return nullptr;
       } else if (decoration[0] == SpvDecorationNonWritable) {
         // WGSL doesn't represent individual members as non-writable. Instead,
         // apply the ReadOnly access control to the containing struct if all
@@ -945,6 +952,10 @@
         ast_member_ty, std::move(ast_member_decorations));
     ast_members.push_back(ast_struct_member);
   }
+  if (is_per_vertex_struct) {
+    // We're replacing it by the Position builtin alone.
+    return nullptr;
+  }
 
   // Now make the struct.
   auto* ast_struct = create<ast::Struct>(Source{}, std::move(ast_members),
@@ -1010,10 +1021,11 @@
   }
   // Manufacture a type for the gl_Position varible if we have to.
   if ((builtin_position_.struct_type_id != 0) &&
-      (builtin_position_.member_pointer_type_id == 0)) {
-    builtin_position_.member_pointer_type_id = type_mgr_->FindPointerToType(
-        builtin_position_.member_type_id, builtin_position_.storage_class);
-    ConvertType(builtin_position_.member_pointer_type_id);
+      (builtin_position_.position_member_pointer_type_id == 0)) {
+    builtin_position_.position_member_pointer_type_id =
+        type_mgr_->FindPointerToType(builtin_position_.position_member_type_id,
+                                     builtin_position_.storage_class);
+    ConvertType(builtin_position_.position_member_pointer_type_id);
   }
   return success_;
 }
@@ -1208,7 +1220,7 @@
     auto* var = MakeVariable(
         builtin_position_.per_vertex_var_id,
         enum_converter_.ToStorageClass(builtin_position_.storage_class),
-        ConvertType(builtin_position_.member_type_id), false, nullptr,
+        ConvertType(builtin_position_.position_member_type_id), false, nullptr,
         ast::VariableDecorationList{
             create<ast::BuiltinDecoration>(Source{}, ast::Builtin::kPosition),
         });
diff --git a/src/reader/spirv/parser_impl.h b/src/reader/spirv/parser_impl.h
index 4e2741d..b34177c 100644
--- a/src/reader/spirv/parser_impl.h
+++ b/src/reader/spirv/parser_impl.h
@@ -373,16 +373,18 @@
     /// The ID for the gl_PerVertex struct containing the Position builtin.
     uint32_t struct_type_id = 0;
     /// The member index for the Position builtin within the struct.
-    uint32_t member_index = 0;
+    uint32_t position_member_index = 0;
+    /// The member index for the PointSize builtin within the struct.
+    uint32_t pointsize_member_index = 0;
     /// The ID for the member type, which should map to vec4<f32>.
-    uint32_t member_type_id = 0;
+    uint32_t position_member_type_id = 0;
     /// The ID of the type of a pointer to the struct in the Output storage
     /// class class.
     uint32_t pointer_type_id = 0;
     /// The SPIR-V storage class.
     SpvStorageClass storage_class = SpvStorageClassOutput;
     /// The ID of the type of a pointer to the Position member.
-    uint32_t member_pointer_type_id = 0;
+    uint32_t position_member_pointer_type_id = 0;
     /// The ID of the gl_PerVertex variable, if it was declared.
     /// We'll use this for the gl_Position variable instead.
     uint32_t per_vertex_var_id = 0;
diff --git a/src/reader/spirv/parser_impl_module_var_test.cc b/src/reader/spirv/parser_impl_module_var_test.cc
index 7ab05d1..e38bba3 100644
--- a/src/reader/spirv/parser_impl_module_var_test.cc
+++ b/src/reader/spirv/parser_impl_module_var_test.cc
@@ -243,8 +243,8 @@
   EXPECT_TRUE(p->error().empty()) << p->error();
   const auto& position_info = p->GetBuiltInPositionInfo();
   EXPECT_EQ(position_info.struct_type_id, 10u);
-  EXPECT_EQ(position_info.member_index, 0u);
-  EXPECT_EQ(position_info.member_type_id, 12u);
+  EXPECT_EQ(position_info.position_member_index, 0u);
+  EXPECT_EQ(position_info.position_member_type_id, 12u);
   EXPECT_EQ(position_info.pointer_type_id, 11u);
   EXPECT_EQ(position_info.storage_class, SpvStorageClassOutput);
   EXPECT_EQ(position_info.per_vertex_var_id, 1u);
@@ -307,8 +307,9 @@
   )";
   auto p = parser(test::Assemble(assembly));
   EXPECT_FALSE(p->BuildAndParseInternalModule());
-  EXPECT_THAT(p->error(), Eq("operations producing a per-vertex structure are "
-                             "not supported: %1000 = OpUndef %11"))
+  EXPECT_THAT(p->error(),
+              Eq("operations producing a pointer to a per-vertex structure are "
+                 "not supported: %1000 = OpUndef %11"))
       << p->error();
 }
 
@@ -343,6 +344,61 @@
       << module_str;
 }
 
+TEST_F(
+    SpvParserTest,
+    ModuleScopeVar_BuiltinPosition_StorePosition_PerVertexStructOutOfOrderDecl) {
+  const std::string assembly = R"(
+  OpCapability Shader
+  OpCapability Linkage ; so we don't have to declare an entry point
+  OpMemoryModel Logical Simple
+
+ ;  scramble the member indices
+  OpMemberDecorate %10 0 BuiltIn ClipDistance
+  OpMemberDecorate %10 1 BuiltIn CullDistance
+  OpMemberDecorate %10 2 BuiltIn Position
+  OpMemberDecorate %10 3 BuiltIn PointSize
+  %void = OpTypeVoid
+  %voidfn = OpTypeFunction %void
+  %float = OpTypeFloat 32
+  %12 = OpTypeVector %float 4
+  %uint = OpTypeInt 32 0
+  %uint_0 = OpConstant %uint 0
+  %uint_1 = OpConstant %uint 1
+  %uint_2 = OpConstant %uint 2
+  %arr = OpTypeArray %float %uint_1
+  %10 = OpTypeStruct %arr %arr %12 %float
+  %11 = OpTypePointer Output %10
+  %1 = OpVariable %11 Output
+
+  %ptr_v4float = OpTypePointer Output %12
+  %nil = OpConstantNull %12
+
+  %main = OpFunction %void None %voidfn
+  %entry = OpLabel
+  %100 = OpAccessChain %ptr_v4float %1 %uint_2 ; address of the Position member
+  OpStore %100 %nil
+  OpReturn
+  OpFunctionEnd
+  )";
+  auto p = parser(test::Assemble(assembly));
+  EXPECT_TRUE(p->BuildAndParseInternalModule());
+  EXPECT_TRUE(p->error().empty());
+  const auto module_str =
+      Demangler().Demangle(p->get_module(), p->get_module().to_str());
+  EXPECT_THAT(module_str, HasSubstr(R"(
+    Assignment{
+      Identifier[not set]{gl_Position}
+      TypeConstructor[not set]{
+        __vec_4__f32
+        ScalarConstructor[not set]{0.000000}
+        ScalarConstructor[not set]{0.000000}
+        ScalarConstructor[not set]{0.000000}
+        ScalarConstructor[not set]{0.000000}
+      }
+    })"))
+      << module_str;
+}
+
 TEST_F(SpvParserTest,
        ModuleScopeVar_BuiltinPosition_StorePositionMember_OneAccessChain) {
   const std::string assembly = PerVertexPreamble() + R"(
@@ -376,13 +432,13 @@
        ModuleScopeVar_BuiltinPosition_StorePositionMember_TwoAccessChain) {
   // The algorithm is smart enough to collapse it down.
   const std::string assembly = PerVertexPreamble() + R"(
-  %ptr_v4float = OpTypePointer Output %12
+  %ptr = OpTypePointer Output %12
   %ptr_float = OpTypePointer Output %float
   %nil = OpConstantNull %float
 
   %main = OpFunction %void None %voidfn
   %entry = OpLabel
-  %100 = OpAccessChain %ptr_v4float %1 %uint_0 ; address of the Position member
+  %100 = OpAccessChain %ptr %1 %uint_0 ; address of the Position member
   %101 = OpAccessChain %ptr_float %100 %uint_1 ; address of the Position.y member
   OpStore %101 %nil
   OpReturn
@@ -407,22 +463,166 @@
       << module_str;
 }
 
-TEST_F(SpvParserTest, ModuleScopeVar_BuiltinPointSize_NotSupported) {
+TEST_F(SpvParserTest, ModuleScopeVar_BuiltinPointSize_Write1_IsErased) {
   const std::string assembly = PerVertexPreamble() + R"(
-  %ptr_v4float = OpTypePointer Output %12
-  %nil = OpConstantNull %12
+  %ptr = OpTypePointer Output %float
+  %one = OpConstant %float 1.0
 
   %main = OpFunction %void None %voidfn
   %entry = OpLabel
-  %100 = OpAccessChain %ptr_v4float %1 %uint_1 ; address of the PointSize member
-  OpStore %100 %nil
+  %100 = OpAccessChain %ptr %1 %uint_1 ; address of the PointSize member
+  OpStore %100 %one
+  OpReturn
+  OpFunctionEnd
+  )";
+  auto p = parser(test::Assemble(assembly));
+  EXPECT_TRUE(p->BuildAndParseInternalModule());
+  EXPECT_TRUE(p->error().empty());
+  const auto module_str =
+      Demangler().Demangle(p->get_module(), p->get_module().to_str());
+  EXPECT_EQ(module_str, R"(Module{
+  Variable{
+    Decorations{
+      BuiltinDecoration{position}
+    }
+    gl_Position
+    out
+    __vec_4__f32
+  }
+  Function x_14 -> __void
+  ()
+  {
+    Return{}
+  }
+}
+)") << module_str;
+}
+
+TEST_F(SpvParserTest, ModuleScopeVar_BuiltinPointSize_WriteNon1_IsError) {
+  const std::string assembly = PerVertexPreamble() + R"(
+  %ptr = OpTypePointer Output %float
+  %999 = OpConstant %float 2.0
+
+  %main = OpFunction %void None %voidfn
+  %entry = OpLabel
+  %100 = OpAccessChain %ptr %1 %uint_1 ; address of the PointSize member
+  OpStore %100 %999
   OpReturn
   OpFunctionEnd
   )";
   auto p = parser(test::Assemble(assembly));
   EXPECT_FALSE(p->BuildAndParseInternalModule());
-  EXPECT_THAT(p->error(), Eq("accessing per-vertex member 1 is not supported. "
-                             "Only Position is supported"));
+  EXPECT_THAT(p->error(),
+              HasSubstr("cannot store a value other than constant 1.0 to "
+                        "PointSize builtin: OpStore %100 %999"));
+}
+
+TEST_F(SpvParserTest, ModuleScopeVar_BuiltinPointSize_ReadReplaced) {
+  const std::string assembly = PerVertexPreamble() + R"(
+  %ptr = OpTypePointer Output %float
+  %nil = OpConstantNull %12
+  %private_ptr = OpTypePointer Private %float
+  %900 = OpVariable %private_ptr Private
+
+  %main = OpFunction %void None %voidfn
+  %entry = OpLabel
+  %100 = OpAccessChain %ptr %1 %uint_1 ; address of the PointSize member
+  %99 = OpLoad %float %100
+  OpStore %900 %99
+  OpReturn
+  OpFunctionEnd
+  )";
+  auto p = parser(test::Assemble(assembly));
+  EXPECT_TRUE(p->BuildAndParseInternalModule());
+  EXPECT_TRUE(p->error().empty());
+  const auto module_str =
+      Demangler().Demangle(p->get_module(), p->get_module().to_str());
+  EXPECT_EQ(module_str, R"(Module{
+  Variable{
+    x_900
+    private
+    __f32
+  }
+  Variable{
+    Decorations{
+      BuiltinDecoration{position}
+    }
+    gl_Position
+    out
+    __vec_4__f32
+  }
+  Function x_15 -> __void
+  ()
+  {
+    Assignment{
+      Identifier[not set]{x_900}
+      ScalarConstructor[not set]{1.000000}
+    }
+    Return{}
+  }
+}
+)") << module_str;
+}
+
+TEST_F(
+    SpvParserTest,
+    ModuleScopeVar_BuiltinPointSize_WriteViaCopyObjectPriorAccess_Unsupported) {
+  const std::string assembly = PerVertexPreamble() + R"(
+  %ptr = OpTypePointer Output %float
+  %nil = OpConstantNull %12
+
+  %main = OpFunction %void None %voidfn
+  %entry = OpLabel
+  %20 = OpCopyObject %11 %1
+  %100 = OpAccessChain %20 %1 %uint_1 ; address of the PointSize member
+  OpStore %100 %nil
+  OpReturn
+  OpFunctionEnd
+  )";
+  auto p = parser(test::Assemble(assembly));
+  EXPECT_FALSE(p->BuildAndParseInternalModule()) << p->error();
+  EXPECT_THAT(
+      p->error(),
+      HasSubstr("operations producing a pointer to a per-vertex structure are "
+                "not supported: %20 = OpCopyObject %11 %1"));
+}
+
+TEST_F(
+    SpvParserTest,
+    ModuleScopeVar_BuiltinPointSize_WriteViaCopyObjectPostAccessChainErased) {
+  const std::string assembly = PerVertexPreamble() + R"(
+  %ptr = OpTypePointer Output %12
+  %one = OpConstant %float 1.0
+
+  %main = OpFunction %void None %voidfn
+  %entry = OpLabel
+  %100 = OpAccessChain %ptr %1 %uint_1 ; address of the PointSize member
+  %101 = OpCopyObject %ptr %100
+  OpStore %101 %one
+  OpReturn
+  OpFunctionEnd
+  )";
+  auto p = parser(test::Assemble(assembly));
+  EXPECT_TRUE(p->BuildAndParseInternalModule()) << p->error();
+  EXPECT_TRUE(p->error().empty());
+  const auto module_str =
+      Demangler().Demangle(p->get_module(), p->get_module().to_str());
+  EXPECT_EQ(module_str, R"(Module{
+  Variable{
+    Decorations{
+      BuiltinDecoration{position}
+    }
+    gl_Position
+    out
+    __vec_4__f32
+  }
+  Function x_14 -> __void
+  ()
+  {
+    Return{}
+  }
+}
+)") << module_str;
 }
 
 TEST_F(SpvParserTest, ModuleScopeVar_BuiltinClipDistance_NotSupported) {
@@ -441,8 +641,9 @@
   )";
   auto p = parser(test::Assemble(assembly));
   EXPECT_FALSE(p->BuildAndParseInternalModule());
-  EXPECT_THAT(p->error(), Eq("accessing per-vertex member 2 is not supported. "
-                             "Only Position is supported"));
+  EXPECT_EQ(p->error(),
+            "accessing per-vertex member 2 is not supported. Only Position is "
+            "supported, and PointSize is ignored");
 }
 
 TEST_F(SpvParserTest, ModuleScopeVar_BuiltinCullDistance_NotSupported) {
@@ -461,8 +662,9 @@
   )";
   auto p = parser(test::Assemble(assembly));
   EXPECT_FALSE(p->BuildAndParseInternalModule());
-  EXPECT_THAT(p->error(), Eq("accessing per-vertex member 3 is not supported. "
-                             "Only Position is supported"));
+  EXPECT_EQ(p->error(),
+            "accessing per-vertex member 3 is not supported. Only Position is "
+            "supported, and PointSize is ignored");
 }
 
 TEST_F(SpvParserTest, ModuleScopeVar_BuiltinPerVertex_MemberIndex_NotConstant) {