From 98f87c7d0854d24fbe7447b602354b1940a1dfe7 Mon Sep 17 00:00:00 2001 From: Sergey Dvoynikov Date: Tue, 22 Sep 2026 17:52:28 +0200 Subject: [PATCH] fix(core): validate vertex buffer arrayStride and attribute containment ValidateRenderPipelineDescriptor had no vertex buffer checks, so any arrayStride reached the HAL and the native backends disagreed about it: software drops a stride-0 draw, GLES reads 0 as tightly packed, Metal sets a zero stride with no constant step function, and a stride of 30 passes on Vulkan while failing in the browser and on GLES. Add the WebGPU GPUVertexBufferLayout checks: arrayStride % 4 == 0, arrayStride <= maxVertexBufferArrayStride (skipped when the limit is unset), and offset + format size within arrayStride (or within the limit when arrayStride is 0). arrayStride 0 is rejected explicitly until a native backend emulates broadcast. Four new CreateRenderPipelineErrorKind values, appended after CreateRenderPipelineErrorHAL, and fields naming the buffer, stride and attribute. --- core/error.go | 53 +++++++++++++- core/validate.go | 80 +++++++++++++++++++++ core/validate_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 2 deletions(-) diff --git a/core/error.go b/core/error.go index 3d6a108..57d578a 100644 --- a/core/error.go +++ b/core/error.go @@ -553,6 +553,24 @@ const ( CreateRenderPipelineErrorDepthFormatNoStencilAspect // CreateRenderPipelineErrorHAL indicates the HAL backend failed to create the pipeline. CreateRenderPipelineErrorHAL + // CreateRenderPipelineErrorVertexStrideMisaligned indicates a vertex buffer + // arrayStride that is not a multiple of 4. + // WebGPU spec: GPUVertexBufferLayout arrayStride must be a multiple of 4. + // Rust: pipeline::CreateRenderPipelineError::UnalignedVertexStride + CreateRenderPipelineErrorVertexStrideMisaligned + // CreateRenderPipelineErrorVertexStrideTooLarge indicates a vertex buffer + // arrayStride above Limits.MaxVertexBufferArrayStride. + // Rust: pipeline::CreateRenderPipelineError::VertexStrideTooLarge + CreateRenderPipelineErrorVertexStrideTooLarge + // CreateRenderPipelineErrorVertexAttributeOutOfStride indicates a vertex + // attribute whose offset plus format size runs past its buffer's arrayStride + // (or past Limits.MaxVertexBufferArrayStride when arrayStride is 0). + CreateRenderPipelineErrorVertexAttributeOutOfStride + // CreateRenderPipelineErrorVertexStrideZero indicates a vertex buffer with + // arrayStride 0. WebGPU allows it (every vertex reads the same element), but + // the native backends do not emulate it yet and disagree about what 0 means, + // so it is rejected until they do. + CreateRenderPipelineErrorVertexStrideZero ) // CreateRenderPipelineError represents an error during render pipeline creation. @@ -565,8 +583,23 @@ type CreateRenderPipelineError struct { // TargetIndex is the color target index for format errors. TargetIndex uint32 // Format is the texture format that caused the error. - Format string - HALError error + Format string + // BufferIndex is the vertex buffer index for vertex layout errors. + BufferIndex uint32 + // ArrayStride is the offending vertex buffer arrayStride for vertex layout errors. + ArrayStride uint64 + // MaxArrayStride is Limits.MaxVertexBufferArrayStride for vertex layout errors. + MaxArrayStride uint32 + // AttributeIndex is the attribute index within the vertex buffer for + // CreateRenderPipelineErrorVertexAttributeOutOfStride. + AttributeIndex uint32 + // AttributeOffset is the attribute byte offset for + // CreateRenderPipelineErrorVertexAttributeOutOfStride. + AttributeOffset uint64 + // AttributeFormat is the attribute vertex format for + // CreateRenderPipelineErrorVertexAttributeOutOfStride. + AttributeFormat string + HALError error } // Error implements the error interface. @@ -607,6 +640,22 @@ func (e *CreateRenderPipelineError) Error() string { label, e.Format) case CreateRenderPipelineErrorHAL: return fmt.Sprintf("render pipeline %q: HAL error: %v", label, e.HALError) + case CreateRenderPipelineErrorVertexStrideMisaligned: + return fmt.Sprintf("render pipeline %q: vertex buffer [%d] arrayStride %d is not a multiple of 4", + label, e.BufferIndex, e.ArrayStride) + case CreateRenderPipelineErrorVertexStrideTooLarge: + return fmt.Sprintf("render pipeline %q: vertex buffer [%d] arrayStride %d exceeds maxVertexBufferArrayStride %d", + label, e.BufferIndex, e.ArrayStride, e.MaxArrayStride) + case CreateRenderPipelineErrorVertexAttributeOutOfStride: + if e.ArrayStride == 0 { + return fmt.Sprintf("render pipeline %q: vertex buffer [%d] attribute [%d] (%s at offset %d) exceeds maxVertexBufferArrayStride %d", + label, e.BufferIndex, e.AttributeIndex, e.AttributeFormat, e.AttributeOffset, e.MaxArrayStride) + } + return fmt.Sprintf("render pipeline %q: vertex buffer [%d] attribute [%d] (%s at offset %d) does not fit in arrayStride %d", + label, e.BufferIndex, e.AttributeIndex, e.AttributeFormat, e.AttributeOffset, e.ArrayStride) + case CreateRenderPipelineErrorVertexStrideZero: + return fmt.Sprintf("render pipeline %q: vertex buffer [%d] arrayStride 0 (broadcast) is not supported on native backends yet", + label, e.BufferIndex) default: return fmt.Sprintf("render pipeline %q: unknown error", label) } diff --git a/core/validate.go b/core/validate.go index d550e0c..227ac57 100644 --- a/core/validate.go +++ b/core/validate.go @@ -418,6 +418,11 @@ func ValidateRenderPipelineDescriptor(desc *hal.RenderPipelineDescriptor, limits } } + // RP10: Vertex buffer layouts. + if err := validateVertexBuffers(desc.Vertex.Buffers, label, limits); err != nil { + return err + } + // RP3-RP6: Fragment stage validation (if present). if desc.Fragment != nil { if err := validateFragmentStage(desc.Fragment, label, limits); err != nil { @@ -485,6 +490,81 @@ func ValidateRenderPipelineDescriptor(desc *hal.RenderPipelineDescriptor, limits return validateRenderPipelineFormatFeatures(desc, features) } +// validateVertexBuffers checks RP10 vertex buffer layout constraints. +// +// WebGPU spec (GPUVertexBufferLayout validation): +// - arrayStride must be a multiple of 4; +// - arrayStride must not exceed maxVertexBufferArrayStride; +// - every attribute's offset + format size must fit in arrayStride, or in +// maxVertexBufferArrayStride when arrayStride is 0. +// +// arrayStride 0 is legal WebGPU and means every vertex reads the same element. +// The native backends do not emulate it and disagree about what 0 means +// (software drops the draw, GLES reads it as tightly packed, Metal sets a zero +// stride without a constant step function), so it is rejected here until a +// backend emulates it. The attribute check above already handles stride 0 the +// way the spec does, so lifting this rejection needs no other change. +func validateVertexBuffers(buffers []gputypes.VertexBufferLayout, label string, limits gputypes.Limits) error { + maxStride := uint64(limits.MaxVertexBufferArrayStride) + for i := range buffers { + vb := &buffers[i] + bufferIndex := uint32(i) + + // RP10a: arrayStride must be a multiple of 4. + if vb.ArrayStride%4 != 0 { + return &CreateRenderPipelineError{ + Kind: CreateRenderPipelineErrorVertexStrideMisaligned, + Label: label, + BufferIndex: bufferIndex, + ArrayStride: vb.ArrayStride, + } + } + + // RP10b: arrayStride must not exceed the device limit (skipped when the limit is unset). + if maxStride > 0 && vb.ArrayStride > maxStride { + return &CreateRenderPipelineError{ + Kind: CreateRenderPipelineErrorVertexStrideTooLarge, + Label: label, + BufferIndex: bufferIndex, + ArrayStride: vb.ArrayStride, + MaxArrayStride: limits.MaxVertexBufferArrayStride, + } + } + + // RP10c: each attribute must fit in the stride, or in the limit when the stride is 0. + bound := vb.ArrayStride + if bound == 0 { + bound = maxStride + } + if bound > 0 { + for j, attr := range vb.Attributes { + if attr.Offset+attr.Format.Size() > bound { + return &CreateRenderPipelineError{ + Kind: CreateRenderPipelineErrorVertexAttributeOutOfStride, + Label: label, + BufferIndex: bufferIndex, + ArrayStride: vb.ArrayStride, + MaxArrayStride: limits.MaxVertexBufferArrayStride, + AttributeIndex: uint32(j), + AttributeOffset: attr.Offset, + AttributeFormat: attr.Format.String(), + } + } + } + } + + // RP10d: arrayStride 0 (broadcast) is not supported on native backends yet. + if vb.ArrayStride == 0 { + return &CreateRenderPipelineError{ + Kind: CreateRenderPipelineErrorVertexStrideZero, + Label: label, + BufferIndex: bufferIndex, + } + } + } + return nil +} + // validateFragmentStage checks RP3-RP6 fragment stage constraints. func validateFragmentStage(frag *hal.FragmentState, label string, limits gputypes.Limits) error { // RP3: Fragment module must not be nil. diff --git a/core/validate_test.go b/core/validate_test.go index 27ec3ec..234e85a 100644 --- a/core/validate_test.go +++ b/core/validate_test.go @@ -3006,3 +3006,167 @@ func TestIsRenderPassCompatibilityError(t *testing.T) { t.Error("expected IsRenderPassCompatibilityError to return false for unrelated error") } } + +// --- RP10: vertex buffer layout validation --- + +func vertexStrideDesc(buffers ...gputypes.VertexBufferLayout) *hal.RenderPipelineDescriptor { + return &hal.RenderPipelineDescriptor{ + Label: "test", + Vertex: hal.VertexState{ + Module: mockShaderModule{}, + EntryPoint: "vs_main", + Buffers: buffers, + }, + Fragment: &hal.FragmentState{ + Module: mockShaderModule{}, + EntryPoint: "fs_main", + Targets: []gputypes.ColorTargetState{{}}, + }, + Multisample: gputypes.MultisampleState{Count: 1}, + } +} + +func TestValidateRenderPipelineDescriptor_VertexBuffers(t *testing.T) { + float32x4 := func(offset uint64) gputypes.VertexAttribute { + return gputypes.VertexAttribute{Format: gputypes.VertexFormatFloat32x4, Offset: offset} + } + layout := func(stride uint64, attrs ...gputypes.VertexAttribute) gputypes.VertexBufferLayout { + return gputypes.VertexBufferLayout{ArrayStride: stride, StepMode: gputypes.VertexStepModeVertex, Attributes: attrs} + } + + tests := []struct { + name string + buffers []gputypes.VertexBufferLayout + limits func(*gputypes.Limits) + // wantKind is checked only when wantErr is true. + wantErr bool + wantKind CreateRenderPipelineErrorKind + wantBuffer uint32 + wantStride uint64 + wantAttrIdx uint32 + }{ + {name: "no vertex buffers"}, + {name: "stride 4", buffers: []gputypes.VertexBufferLayout{layout(4, gputypes.VertexAttribute{Format: gputypes.VertexFormatFloat32})}}, + {name: "stride 2048 (default limit)", buffers: []gputypes.VertexBufferLayout{layout(2048, float32x4(0))}}, + {name: "attribute exactly fills the stride", buffers: []gputypes.VertexBufferLayout{layout(32, float32x4(0), float32x4(16))}}, + { + name: "stride above an unset limit is not checked", + buffers: []gputypes.VertexBufferLayout{layout(4096, float32x4(0))}, + limits: func(l *gputypes.Limits) { l.MaxVertexBufferArrayStride = 0 }, + }, + { + name: "stride 0 is rejected", + buffers: []gputypes.VertexBufferLayout{layout(0, float32x4(0))}, + wantErr: true, + wantKind: CreateRenderPipelineErrorVertexStrideZero, + wantBuffer: 0, + }, + { + name: "stride 30 is misaligned", + buffers: []gputypes.VertexBufferLayout{layout(32, float32x4(0)), layout(30, float32x4(0))}, + wantErr: true, + wantKind: CreateRenderPipelineErrorVertexStrideMisaligned, + wantBuffer: 1, + wantStride: 30, + }, + { + name: "stride 2052 exceeds the limit", + buffers: []gputypes.VertexBufferLayout{layout(2052, float32x4(0))}, + wantErr: true, + wantKind: CreateRenderPipelineErrorVertexStrideTooLarge, + wantStride: 2052, + }, + { + name: "attribute runs past the stride", + buffers: []gputypes.VertexBufferLayout{layout(28, float32x4(0), float32x4(16))}, + wantErr: true, + wantKind: CreateRenderPipelineErrorVertexAttributeOutOfStride, + wantStride: 28, + wantAttrIdx: 1, + }, + { + name: "stride 0 attribute is bounded by the limit", + buffers: []gputypes.VertexBufferLayout{layout(0, float32x4(2040))}, + wantErr: true, + wantKind: CreateRenderPipelineErrorVertexAttributeOutOfStride, + wantStride: 0, + wantAttrIdx: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limits := gputypes.DefaultLimits() + if tt.limits != nil { + tt.limits(&limits) + } + err := ValidateRenderPipelineDescriptor(vertexStrideDesc(tt.buffers...), limits, gputypes.Features(0)) + if !tt.wantErr { + if err != nil { + t.Fatalf("expected nil error, got: %v", err) + } + return + } + var crpe *CreateRenderPipelineError + if !errors.As(err, &crpe) { + t.Fatalf("expected CreateRenderPipelineError, got %T (%v)", err, err) + } + if crpe.Kind != tt.wantKind { + t.Fatalf("expected kind %v, got %v (%v)", tt.wantKind, crpe.Kind, err) + } + if crpe.BufferIndex != tt.wantBuffer { + t.Errorf("BufferIndex = %d, want %d", crpe.BufferIndex, tt.wantBuffer) + } + if crpe.ArrayStride != tt.wantStride { + t.Errorf("ArrayStride = %d, want %d", crpe.ArrayStride, tt.wantStride) + } + if crpe.AttributeIndex != tt.wantAttrIdx { + t.Errorf("AttributeIndex = %d, want %d", crpe.AttributeIndex, tt.wantAttrIdx) + } + if crpe.Error() == "" { + t.Error("Error message should not be empty") + } + }) + } +} + +func TestCreateRenderPipelineError_VertexBufferMessages(t *testing.T) { + tests := []struct { + name string + err *CreateRenderPipelineError + contains string + }{ + { + name: "misaligned stride", + err: &CreateRenderPipelineError{Kind: CreateRenderPipelineErrorVertexStrideMisaligned, Label: "test", BufferIndex: 1, ArrayStride: 30}, + contains: "arrayStride 30 is not a multiple of 4", + }, + { + name: "stride too large", + err: &CreateRenderPipelineError{Kind: CreateRenderPipelineErrorVertexStrideTooLarge, Label: "test", ArrayStride: 2052, MaxArrayStride: 2048}, + contains: "exceeds maxVertexBufferArrayStride 2048", + }, + { + name: "attribute out of stride", + err: &CreateRenderPipelineError{Kind: CreateRenderPipelineErrorVertexAttributeOutOfStride, Label: "test", ArrayStride: 28, AttributeIndex: 1, AttributeOffset: 16, AttributeFormat: "Float32x4"}, + contains: "does not fit in arrayStride 28", + }, + { + name: "attribute out of limit at stride 0", + err: &CreateRenderPipelineError{Kind: CreateRenderPipelineErrorVertexAttributeOutOfStride, Label: "test", MaxArrayStride: 2048, AttributeOffset: 2040, AttributeFormat: "Float32x4"}, + contains: "exceeds maxVertexBufferArrayStride 2048", + }, + { + name: "stride zero", + err: &CreateRenderPipelineError{Kind: CreateRenderPipelineErrorVertexStrideZero, Label: "test"}, + contains: "broadcast) is not supported on native backends", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if msg := tt.err.Error(); !strings.Contains(msg, tt.contains) { + t.Errorf("expected error to contain %q, got %q", tt.contains, msg) + } + }) + } +}