Skip to content

fix(core): validate vertex buffer arrayStride and attribute containment - #359

Open
dvoyni wants to merge 1 commit into
gogpu:mainfrom
dvoyni:fix/vertex-array-stride-validation
Open

dvoyni wants to merge 1 commit into
gogpu:mainfrom
dvoyni:fix/vertex-array-stride-validation

Conversation

@dvoyni

@dvoyni dvoyni commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

core.ValidateRenderPipelineDescriptor checks the vertex module and entry point, the fragment stage, the colour and depth formats and the sample count, but it has no vertex buffer check at all. Any arrayStride therefore reaches the HAL, and the native backends disagree about what it means.

arrayStride: 0 is legal WebGPU and means "broadcast": every vertex reads the same offset. The CTS tests it as a pass case (vertex_state.spec.ts, vertex_buffer_array_stride_limit_alignment). Dawn and Rust wgpu check % 4 and the limit, and they emulate stride 0 on Metal with a constant step function. Here, in v0.34.5:

backend site (v0.34.5) stride 0 does
software raster hal/software/draw.go:461-464 return nil: the whole draw is dropped
software VS path hal/software/draw.go:664-667 continue: the attribute stays zero, not broadcast
Metal hal/metal/device.go:949-951 setStride:0, setStepRate:1, no constant step function
GLES hal/gles/command.go:1413-1424 reaches glVertexAttribPointer, where 0 means tightly packed
Vulkan hal/vulkan/pipeline.go:104-107 passed through (MoltenVK containment not verified)
DX12 hal/dx12/device.go:2514 passed through (not verified)
browser internal/browser/convert_resources.go:488 set on the JS object, so browser validation applies

A misaligned stride behaves the same way: a 30-byte stride succeeds on Vulkan and fails in the browser and on GLES.

What this changes

Validation only. No HAL file changes and no existing signature changes. ValidateRenderPipelineDescriptor runs before any HAL on the native path (device_native.go), so one check covers every native backend. For each entry in desc.Vertex.Buffers:

  1. ArrayStride % 4 == 0.
  2. ArrayStride <= limits.MaxVertexBufferArrayStride, skipped when the limit is zero, as the file already does for other limits.
  3. Attribute containment: Offset + format size <= ArrayStride. For stride 0 it is bounded by MaxVertexBufferArrayStride instead, per the spec's special case, so it stays right once check 4 is lifted.
  4. ArrayStride == 0 is rejected explicitly, with a message saying broadcast is not supported on native backends yet. Rejecting beats emulating per backend here: failing loudly on every path is better than working on one. The check can be removed once a backend emulates broadcast.

New exported API, additive only:

  • CreateRenderPipelineErrorKind values, appended after CreateRenderPipelineErrorHAL so existing values do not move: CreateRenderPipelineErrorVertexStrideMisaligned, CreateRenderPipelineErrorVertexStrideTooLarge, CreateRenderPipelineErrorVertexAttributeOutOfStride, CreateRenderPipelineErrorVertexStrideZero.
  • CreateRenderPipelineError fields naming the failure: BufferIndex, ArrayStride, MaxArrayStride, AttributeIndex, AttributeOffset, AttributeFormat.

The browser path is unchanged, because browser WebGPU already validates this.

Tests

Table tests in core/validate_test.go:

  • pass: stride 4, stride 2048, an attribute that exactly fills the stride, no vertex buffers, and a stride above an unset (zero) limit;
  • fail with the new kinds: stride 0, stride 30 (on the second buffer, to check the reported index), stride 2052, an attribute that runs past the stride, and a stride-0 attribute past MaxVertexBufferArrayStride;
  • the messages of the four new kinds.

go test ./... passes.

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.
@codecov

codecov Bot commented Sep 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@dvoyni dvoyni mentioned this pull request Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant