Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions core/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
80 changes: 80 additions & 0 deletions core/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
164 changes: 164 additions & 0 deletions core/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
Loading