Skip to content

release: v0.34.9 - #386

Merged
kolkov merged 33 commits into
mainfrom
release/v0.34.9
Nov 8, 2026
Merged

kolkov merged 33 commits into
mainfrom
release/v0.34.9

Conversation

@kolkov

@kolkov kolkov commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

A software backend correctness release: the CPU backend now renders and samples the way the GPU backends do, checked by readback against Vulkan, DX12 and GLES.

Fixed

  • Software backend: encoder commands run at Queue.Submit, as on the GPU backends (P1-SW-031). They ran when recorded, so a command buffer that was never submitted still took effect, and a Queue.WriteBuffer/WriteTexture made between recording a copy and submitting it was overwritten in the wrong order.
  • Software backend: derivatives and implicit-LOD sampling work (P2-SW-020). Fragment shaders that take derivatives or sample with an implicit level of detail run in 2x2 quads with helper invocations. dpdx, dpdy and fwidth (fine, coarse and plain) return real differences instead of 0, and textureSample, textureSampleBias and textureSampleCompare pick the mip level from the coordinate's screen-space derivatives instead of always the base level. A discarded invocation stops instead of becoming a helper (see Known issues).
  • Software backend: perspective-correct interpolation (P1-SW-037). Varyings and texture coordinates were interpolated linearly in screen space; they now interpolate perspective-correctly, @interpolate(linear) is honoured, depth is linear in screen space, and the fragment position's w is 1/w as WGSL defines it.
  • Software backend: primitives are clipped in clip space before the perspective divide (P1-SW-027). A triangle with a vertex behind the eye no longer mirrors through the center of projection, and a triangle with a far-off vertex no longer vanishes.
  • Software backend: SetViewport applies to every draw, x, y, size and depth range (P1-SW-036). Every draw used the whole render target and depth 0..1.
  • Software backend: back-facing triangles use the back stencil face state, and the stencil test runs when either face differs from the default (P2-SW-030).
  • Software backend: integer and bool vectors are exact 32-bit values (P1-SW-019). The SPIR-V interpreter held integer vector lanes as float32, so values above 2^24 lost their low bits and bit operations, shuffles and composites on integer vectors gave wrong lanes. Integers in uniforms, storage buffers, integer vertex formats, flat varyings and textures now read, compute and store bit-exact.
  • Software backend: missing shader operations execute. all(), any(), vector-times-matrix, isnan/isinf, quantizeToF16, packed dot4, textureGather, textureGatherCompare, textureSampleCompare(Level) and the remaining atomics returned zeros before. Integer vertex formats (uint32/sint32 x1-x4) read as zero before.
  • Software backend: unsupported SPIR-V fails pipeline creation with an error naming it (the opcode, f16 or ray query), where it was skipped silently. Shader compile and parse errors are returned from CreateRenderPipeline and CreateComputePipeline.
  • Software backend: image loads are clamped in the shader, as on Vulkan (naga Restrict). An out-of-bounds textureLoad reads an in-bounds texel; a negative coordinate reads the last texel, as on Vulkan.
  • Software backend: BC1-BC7 and ETC2/EAC textures can be sampled and loaded (P1-SW-029), decoded per texel as GPUs decode them (checked bit for bit against Vulkan, DX12 and GLES readback). The software adapter now offers texture-compression-bc and texture-compression-etc2. ASTC is not offered yet.
  • Software backend: RowsPerImage of an upload or buffer copy counts texel block rows, as WebGPU defines it. Copies of several layers of a block-compressed texture put every layer after the first at the wrong offset.
  • Software backend no longer advertises FeatureRayQuery: its interpreter does not execute ray queries (P1-SW-028). Executing them is planned (WebGPU RayQuery Support #369).
  • GLES: integer vertex formats reach the shader as integers (P1-GLES-026). Every attribute went through glVertexAttribPointer, which converts to float, so uint/sint vertex inputs read the float's bits. They now use glVertexAttribIPointer, as Rust wgpu-hal does. float16x2, float16x4 and unorm10-10-10-2 vertex formats were read as float32x4 and are now mapped.
  • GLES: flat varyings take the first vertex of a primitive, as WGSL's @interpolate(flat) requires (glProvokingVertex on desktop GL; on OpenGL ES only with GL_ANGLE_provoking_vertex). GL's default is the last vertex.
  • Compressed textures that are not 2D or not block-aligned are refused at CreateTexture, as WebGPU and Rust wgpu-core require (P2-VAL-017): a width or height that is not a multiple of the block, a 1D compressed texture, a 3D compressed texture (the sliced-3D features are not offered yet). Before, such input reached the driver on Vulkan.

Changed

  • deps: naga v0.21.3 → v0.21.4. GLES no longer fails to compile a textureLoad, textureDimensions or textureNumLevels with a u32 level on desktop GL 4.3+; primitive_index compiles on OpenGL ES; compound assignment through a pointer parameter (*p += x) is accepted.
  • CreateTextureErrorInvalidCompressedDimension and CreateTextureErrorNotMultipleOfBlockSize (new core.CreateTextureErrorKind values) and CreateTextureError.Dimension.
  • hal/software: Queue.Submit returns an error for a nil command buffer or one not created by the software backend, before running any of the batch.
  • hal/software/shader: Module.Execute reads an integer input given as a float Value as its bits, no longer by value. Pass integer inputs as integer Values, as its documentation says.

Known issues

  • Software backend: after a non-uniform discard, the discarded invocation ends instead of becoming a helper (naga emits OpKill), so a non-linear derivative such as dpdxFine(x*y) in that quad differs from the GPU backends, and an alpha test followed by a perspective-warped textureSample can pick a slightly different mip level.
  • DX12: the fragment @builtin(position).w reads w instead of 1/w (a naga HLSL/DXIL issue, to be fixed in naga).
  • OpenGL ES contexts without GL_ANGLE_provoking_vertex (Mesa ES, most Android) still give flat varyings the last vertex.
  • -tags rust with go-webgpu/webgpu v0.5.5 remains unusable for real rendering (0.34.6 known issues); in addition, programs abort at adapter request on Linux amd64 and Intel macOS. These are fixed in go-webgpu/webgpu's next release.

kolkov added 24 commits November 8, 2026 06:35
SetVertexBufferCommand configured every attribute with glVertexAttribPointer,
which converts the values to float, so a uvec4 or ivec4 shader input read the
float's bits. Integer formats now go through glVertexAttribIPointer, as Rust
wgpu-hal does by VertexAttribKind (src/gles/queue.rs SetVertexAttribute).
glVertexAttribIPointer is core in GL 3.0 and ES 3.0 and joins the required
entry points on Windows and Linux.

vertexFormatToGL returns the integer flag with the size, type and
normalization, and maps Float16x2, Float16x4 and Unorm1010102, which fell to
the Float32x4 default before (src/gles/conv.rs describe_vertex_format).

Found by a flat integer varying readback: GLES returned 0x4f4afebb for
0x89abcdef (P1-GLES-026).
WGSL's @interpolate(flat) means @interpolate(flat, first), and integer
varyings are always flat, but GL gives flat varyings the last vertex by
default. Device open now sets glProvokingVertex(GL_FIRST_VERTEX_CONVENTION),
core in desktop GL 3.2. OpenGL ES has it only with GL_ANGLE_provoking_vertex;
an ES context without it keeps the last vertex, which only
@interpolate(flat, either) allows.

Rust wgpu-hal never sets the provoking vertex, so this follows the WGSL spec
(Interpolation) rather than Rust (P1-GLES-026).
…reation

The software backend dropped every failure between the shader source and
the interpreter: naga's lowering and SPIR-V errors in CreateShaderModule,
and the ParseModule error in ParsedModule. A render pipeline built from such
a module was created and drew nothing; a compute pipeline failed with
ErrComputeRequiresSPIRV, which did not say why.

The module now parses its SPIR-V once at creation and keeps the error, and
both pipeline creators return it with the stage and entry point, as Rust
wgpu reports backend translation failures at pipeline creation. The root
wraps HAL failures of CreateShaderModule, CreateRenderPipeline and
CreateComputePipeline in the typed core errors (kind HAL), so callers can
match them with errors.As.

ADR-078 step 0 (P1-SW-019).
…struction counter

ADR-078 step 1. Adds tests that pin what the interpreter computes today for
integer and boolean vectors, comparisons, OpAll/OpAny, image queries, atomics
and uniform uvec4 loads, each asserting the current wrong value and the step
that fixes it, plus plain tests for the paths that are right today and gg
shader controls. Adds integer-vector and loop benchmarks and an instruction
counter behind the swinstrcount build tag, and a dynamic-offset compute test
that loads a whole uvec2 uniform.
…ult shapes (ADR-078 step 2)

Adds TagUVec2..4, TagIVec2..4 and TagBVec2..4 with raw 32-bit lanes, the lane kind and shape tables, a precomputed result shape per instruction, and makes every boundary function (buffer writes, sizes, composite index and insert, debugger printing, image coordinates, fragment outputs) handle the new tags. No producer emits them yet.
… ops (ADR-078 steps 3-4)

Integer and bool vectors now come out of every producer with their own
tags and exact 32-bit lanes: constants, OpCompositeConstruct,
OpVectorShuffle, OpUndef, buffer loads, bitcasts, conversions, image
fetches and size queries, the GLSL.std.450 integer functions, frexp
exponents, unpackDouble2x32 and the compute builtins. The integer,
comparison, logical and select ops work lane by lane on those words.

Behavior changes, each to the WGSL definition:
- division by zero gives the dividend and x % 0 gives 0 (was 0 for both);
  INT_MIN / -1 is INT_MIN and INT_MIN % -1 is 0;
- OpSMod takes the divisor's sign (it had OpSRem's semantics);
- OpBitFieldInsert inserts every lane (only lane 0 before).

The expected-failure rows of steps 3 and 4 are flipped to plain checks,
and the 0xDEADBEEF uniform case in hal/allbackends now passes on software.
…mats and flat varyings (ADR-078 step 5)

Integer and bool varyings and vertex attributes cross the rasterizer as the
bit patterns of their 32-bit words, and the rasterizer copies flat and integer
varyings from the provoking vertex instead of interpolating them. Integer
vertex formats (Uint8x2 to Sint32x4) are read zero- or sign-extended to their
words; they returned zeros before. Scalar integer varyings no longer drop to
0, the second triangle of a strip pair takes its first vertex as the
provoking one, and integer stores into storage buffers take the raw word.
…tising ray query (ADR-078 step 6)

The interpreter returned zero for SPIR-V that naga emits from valid WGSL:
OpVectorTimesMatrix (v * m), OpAtomicAnd/Or/Xor, OpIsNan/OpIsInf (naga's
isnan/isinf), OpQuantizeToF16, OpSDotKHR/OpUDotKHR (dot4I8Packed and
dot4U8Packed), OpImageGather, OpImageDrefGather and both
OpImageSampleDref* forms. They now execute: gather reads the base level's
linear footprint in Vulkan lane order with the sampler's address modes,
and the depth compares test ref against each texel (WebGPU order) before
filtering, so linear filtering is PCF. Sampler.Compare carries the
comparison function from the HAL sampler.

The software adapter no longer advertises FeatureRayQuery: the
interpreter does not execute OpRayQuery*, so a ray_query shader could not
run. The CPU BVH build stays reachable through the HAL.

Tests: naga-compiled shader tests for every new opcode, shown to fail on
the previous commit (all zeros), and an allbackends readback of gather,
gatherCompare, sampleCompare and sampleCompareLevel that agrees on
Vulkan, DX12 (FXC and DXIL), GLES and software. Range loops over the
128-byte raster.Triangle index instead of copying (gocritic).
ADR-078 step 7 (P1-SW-019; the interpreter half of P1-SW-017). One table,
supportedOps, lists every function-body opcode the interpreter executes,
with its name and whether it carries a result type and id. decodeInstruction
takes its layout from the table, ParseModule rejects any other opcode in a
function body, the Float16 and ray query capabilities, f16 and the ray query
and acceleration structure types, and run()'s default returns the same
error instead of skipping the instruction. The error names the opcode and
wraps the new shader.ErrUnsupported, and it reaches CreateComputePipeline
and CreateRenderPipeline as a HAL error.

OpAll and OpAny were skipped before, so all() and any() returned false
whatever the input. They now reduce bool vectors of every width.

A table test checks the table against run()'s case labels and the opcode
constants, and an emitter test compiles nine WGSL builtin families through
the software device so a naga change that emits a new opcode fails there.
ADR-078 step 8 (P1-SW-019). Image loads now use naga's Restrict bounds
check policy, as Rust wgpu-hal does on Vulkan without robust image access:
the level, the sample, the array layer and each coordinate are clamped by an
unsigned min against the queried count or size minus one. That clamp is
integer vector arithmetic, which the interpreter now holds exactly, so the
Unchecked stopgap is no longer needed. fetchTexelLevel's own clamp stays as
a second line of defense.

The visible change is at a negative coordinate, level or layer: it now
reads the last texel, level or layer, as on Vulkan, instead of the first.
Both are inside the texture, which is what WGSL allows. The new test covers
1D, 2D with mip levels, 2D arrays and 3D, in and out of bounds; its five
negative-value rows fail on the Unchecked policy.
ADR-078 step 9 (P1-SW-019). Integer and bool vectors are word vectors everywhere since steps 3-8, so the conversions kept for the old forms go: laneBits and its saturating float-to-integer path, the TagArray branches of laneWords, lanePool, uint32Pair, expComponent, imageCoord3 and texelComponents. A float scalar in an integer lane now gives its bits, never its value; valid SPIR-V never puts one there. The typed value rows lose their expected-failure form (each keeps the step that fixed it), the unit tests use typed integer vectors, and a test pins the 48-byte Value size its doc states.
The software rasterizer applied stencilFront to every triangle and ran the stencil test only when stencilFront was not the default, so a pipeline with a stencil state only on the back face drew without one (P2-SW-030). The raster pipeline now keeps a front and a back stencil state and picks one per triangle by IsBackFacing and the pipeline's frontFace (index.bs "stencil test"). The test runs when either face is not the default, as core's isStencilEnabled, Vulkan's stencilTestEnable and Dawn's UsesStencil decide; an Undefined face member counts as its default there too.

TestStencilTwoSided draws two triangles of opposite winding with front increment and back decrement (ccw and cw) and with a back-only replace, then probes each triangle's stencil with compare equal. It failed on software and passes on Vulkan, DX12 (FXC and DXIL), GLES and software.
…implicit-LOD sampling

dpdx, dpdy and fwidth (plain, fine and coarse) returned 0 on the software backend, and implicit-LOD samples always read the base level, because every fragment ran alone. A fragment entry point that reaches a derivative or an implicit-LOD sample (shader.Module.NeedsQuads, through function calls) now runs one 2x2 quad at a time: the interpreter suspends each lane at such an instruction, the quad executor computes it from the lanes' values and resumes them. Fine and plain derivatives take the lane's row or column, coarse ones the top row and left column; an implicit-LOD sample takes its level from the coarse derivatives of its coordinate, one level per quad, with the bias added. Lanes that are uncovered or fail the early depth/stencil test run as helpers, whose buffer, atomic and storage-texture writes are dropped and whose outputs are not written. raster.QuadRasterizer produces the quads with the same coverage and interpolation as Rasterize and reuses its storage; other shaders keep the per-fragment path. P2-SW-020.
The software backend executed encoder commands when they were recorded, so a command buffer that was never submitted still changed resources, and a Queue.WriteBuffer or WriteTexture made between recording a copy and submitting it landed after the copy instead of before it.

The command encoder now records each command as a closure holding its resources into the command buffer, and Queue.Submit runs the command buffers in order, as the GL backend of Rust wgpu replays its command list at submit (wgpu-hal/src/gles/queue.rs). Render and compute passes record their begin, state, draw/dispatch and end commands the same way; render pass Stats are gathered at record time. Submit refuses a command buffer the software backend did not record. Queue writes stay immediate, so they keep their place on the queue timeline.

New tests cover an unsubmitted command buffer and a WriteBuffer between record and submit on every backend, and the software skip in TestStencilUploadQueueOrder is removed. P1-SW-031.
…igned

WebGPU's texture descriptor validation, as Rust wgpu-core's create_texture applies it, allows a block-compressed format only on a 2D texture whose width and height are multiples of the block. Core accepted a BC1 5x5, an ETC2 6x4, a 1D BC1 and a 3D BC1 texture, so the input reached the driver on Vulkan. A 3D BC or ASTC texture needs the sliced-3D features, which gputypes does not have yet, so it is refused, as Rust refuses it without them.

The errors are CreateTextureErrorInvalidCompressedDimension and CreateTextureErrorNotMultipleOfBlockSize (P2-VAL-017).
naga v0.21.4 casts a u32 image-load level to int in GLSL under Restrict and ReadZeroSkipWrite, which the GLES backend uses on GL 4.3+, so a WGSL textureLoad with a u32 level no longer fails to compile there; writes uint(gl_PrimitiveID) on OpenGL ES; and accepts compound assignment through a pointer parameter. The stencil blit goes back to a u32 mip level, and the software emitter test to *p += x.
Add hal/software/internal/texcompress, which decodes one texel of a BC1-BC7 block from that block alone: BC1-BC3 (S3TC), BC4/BC5 (RGTC, unorm and snorm), BC6H (ufloat and sfloat) and BC7. BC6H and BC7 are ported by algorithm from Mesa's texcompress_bptc_tmp.h (27e63a95).

The palette arithmetic is the GPUs', measured by readback: BC1-BC3 colors and BC3 alpha use weights of 256 (round(256k/n)) with rounding, where Mesa divides exactly and truncates; BC4/BC5 use per-endpoint weights of 65536 to an 8.8 value widened by bit replication, and choose the snorm mode after clamping -128 to -127.

testdata/gpu_readback.txt holds every texel of 112 blocks as textureLoad returned them on Vulkan, DX12 and GLES (bit-identical); the test checks each bit for bit. Its generator is the allbackends decode test, wired with the software sampler.
Extend texcompress to ETC2 RGB8, RGB8A1 and RGBA8 (each with its sRGB variant) and EAC R11/RG11 unorm and snorm, per the Khronos Data Format Specification 1.3 ETC2 section: individual, differential, T, H and planar modes, punch-through alpha, and the EAC 11-bit formats (unorm of 2047, snorm of 1023 with a base codeword of -128 read as -127, multiplier 0).

The golden readback gains 72 ETC2/EAC blocks covering every mode; all decode bit for bit.
WebGPU's GPUTexelCopyBufferLayout and hal.ImageDataLayout count rowsPerImage in texel block rows. bufferRowOffset took it for texel rows and divided it by the block height, so a WriteTexture or a buffer copy of several layers of a block-compressed texture placed every image after the first at the wrong offset. Uncompressed formats, whose block is one texel, were not affected.
Texel fetches of the BC1-BC7, ETC2 and EAC formats decode the texel from its own 4x4 block through internal/texcompress, then filtering, mip selection, layers and wrapping work as for any other format; the RGB of an sRGB format decodes to linear after the block decode. The software adapter now offers texture-compression-bc and texture-compression-etc2.

TestCompressedTextureDecode reads every texel of a set of blocks per format with textureLoad on every backend: the software backend must return the committed GPU readback, and Vulkan, DX12 and GLES must return what the software backend returns, bit for bit (sRGB color channels as 8-bit codes, since GPUs approximate the sRGB curve).
The software backend recorded SetViewport and never read it: every draw mapped NDC to the whole render target with depth 0..1. One ViewportTransform now maps clip space to framebuffer coordinates and depth for every draw path (per-fragment, quad, MRT, indexed, indirect, vertex buffers), as the WebGPU spec's rasterization step defines, and rasterization is limited to the pixels whose centers lie in the viewport, intersected with the target and the scissor. The fullscreen blit fast path no longer stands in for a draw whose viewport is not the whole target.

TestViewportMapping and TestViewportMappingIndirect read the result back on every backend; software failed all of them before. BenchmarkSmallTrianglesDraw measures the per-triangle vertex path.

P1-SW-036
The software backend divided every vertex by its w and rasterized the result. A vertex behind the eye (w < 0) was mirrored through the center of projection, and a vertex beyond the ±2^21 pixels the rasterizer snaps to made the whole triangle vanish. A Clipper now clips every triangle in clip space, as the WebGPU spec's primitive clipping step does: exactly against 0 <= z <= w and w > 0, and in x and y against a guard band 8192 pixels past the render target, since the viewport and the scissor already bound the pixels drawn. New vertices interpolate the position and every attribute in clip space and copy flat attributes from the provoking vertex; the clipped polygon keeps the winding and is drawn as a fan. A triangle that needs no clipping keeps its attribute slices and allocates nothing.

The old ClipTriangle, ClipTriangleNearFar, ClipTriangleAgainstPlane(s) and ClipTriangleFast were never called and flipped the winding of one of the two triangles they split a quad into; they are removed with their tests.

TestClipTriangleBehindEye and TestClipTriangleFarVertex read the result back on every backend; software failed both before.

P1-SW-027
Varyings were interpolated linearly in screen space, so textures and
colors swam on any primitive whose vertices had different w. The
rasterizer now keeps 1/w per vertex and weights each perspective lane by
b_i/w_i, normalized, as the WebGPU spec requires (P1-SW-037).

- Project keeps W = 1/w; the position builtin's w is the interpolated
  1/w, as WGSL defines it.
- Depth stays linear in screen space through one helper shared by the
  per-fragment and quad paths.
- @interpolate(linear) inputs carry a NoPerspective lane mask, read from
  the SPIR-V NoPerspective decoration, and use screen barycentrics. A
  vertex clipping creates computes them from the triangle's vertices at
  its clip-space barycentrics, Σ b·w·A / Σ b·w, so they stay finite
  when an intermediate vertex of the clipper rounds to w = 0.
- Attributes are interpolated as A0 + p1(A1-A0) + p2(A2-A0), so an
  attribute equal at all vertices comes back exactly.

New readback tests on every backend cover a perspective quad with
perspective and linear varyings, a texture, depth, position.z/w, a depth
test against a plane, the far-plane clip of a perspective quad, and a
linear varying on a triangle with a vertex behind the eye. On dx12 the
position.w check is left out: naga's HLSL and DXIL backends pass
SV_Position.w through instead of 1/w (naga P1-HLSL-037).
The software backend section of the README and docs/ARCHITECTURE.md describe what v0.34.9 does: commands at Queue.Submit, clip-space clipping, the viewport transform, perspective-correct interpolation, quad derivatives, two-sided stencil and BC/ETC2 sampling; the known gaps left are ASTC, depth-clip-control/frag_depth and helper invocations after discard. The GLES section names the vertex attribute and provoking vertex handling. ROADMAP and AGENTS.md move to v0.34.9 and naga v0.21.4.
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.05882% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
shader_native.go 77.27% 5 Missing and 5 partials ⚠️
device_native.go 88.88% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Raw SPIR-V passed to CreateShaderModule went straight to the software
interpreter, which sizes its value slice by the header Bound and indexes it
by every result and operand ID. A module with an ID at or above Bound, or a
Bound of 0, parsed fine and panicked with index out of range at the first
draw or dispatch inside Queue.Submit (P1-SW-039).

ParseModule now validates what the interpreter relies on, as the SPIR-V
validator and naga's SPIR-V frontend (which Rust wgpu runs at module
creation) do:
- the header Bound is in [1, 4194303], the SPIR-V universal limit;
- every result ID is in (0, Bound);
- every ID operand of a function-body instruction, every result type,
  entry point function and interface, and variable initializer names an ID
  the module defines, found per opcode from the SPIR-V grammar so literal
  operands (switch cases, composite indexes, memory and image operand
  masks) are not mistaken for IDs;
- a type uses only types declared before it, so a struct cannot contain
  itself, and vectors and matrices have 2 to 4 components or columns.

The value slice is sized by the largest defined ID instead of the header
Bound, which may legally be far larger.

A test module declared a pointer before its pointee array, which SPIR-V
forbids; it now declares them in order.
The Go spec lets a compiler fuse x*y + z into one fused multiply-add; the
arm64 backend does, amd64 does not. In the clipper that moved a vertex the
near plane creates from z = 0 to z = -1.5e-8 on arm64, so the clipped
geometry, and the pixels it covers, differed between architectures, and
two clipper tests failed on darwin/arm64 only.

Plane distances and the interpolation in intersect now round each product
with an explicit float32 conversion, which the spec defines to prevent
fusion. The arm64 assembly of the clipper has no fused multiply-adds left
(it had 16).
Every Metal draw passed MTLPrimitiveTypeTriangle whatever the pipeline's
topology: Metal takes the primitive type per draw call, not from the
pipeline state, and the topology was never carried to the encoder. A
triangle strip of four vertices drew one triangle instead of two, and line
and point pipelines drew triangles. TestFlatIntegerVaryings/strip found it
on the macOS runner.

The render pipeline now keeps the MTLPrimitiveType of its topology and
Draw, DrawIndexed, DrawIndirect and DrawIndexedIndirect pass it, as Rust
wgpu-hal does (RenderPipeline.raw_primitive_type, set_render_pipeline).
A draw without a pipeline is not encoded. The indirect command buffer
path stays triangle-list only.
TestCompressedTextureDecode demanded every GPU decode bit for bit what the
software backend returns. That held on Intel Iris Xe, whose readback is the
golden, and failed on the CI runners: WARP and Apple's virtual GPU round the
interpolated BC1-BC5 palette entries differently, by one 8-bit code.

BC1-BC5 texels now compare within one code of the format (1/255 unorm, the
sRGB code for sRGB channels, 1/127 snorm), with -1, 0 and 1 exact. The
Direct3D 11.3 specification (19.5) bounds BC1-BC3 by 1/255 against the
ideal; its 1/65535 and 1/32767 for BC4/BC5 against the ideal are not met by
Intel Iris Xe itself, so they cannot be the test's bound. The texels are
classified from the Khronos Data Format Specification formulas.

Two decodes the Khronos spec leaves to the implementation accept each
value it allows: a signed BC4/BC5 block with red0 = -127 and red1 = -128
(the mode comparison "may vary by implementation"; WARP returns -1 where
Intel returns 1), and a BC7 block of the reserved mode, whose alpha may be
0 or 1.

The virtual GPU of GitHub's macOS runners ("Apple Paravirtual device")
decodes interpolated BC1-BC5 entries up to two codes off; on that adapter
only, an interpolated value between its endpoints is accepted and logged.
BC6H, BC7, ETC2 and EAC still compare bit for bit.
CreateComputePipeline checked the workgroup size against the device limits
only for WGSL modules: a SPIR-V module has no IR, and nothing read its
LocalSize. On the software backend LocalSize 1024 1024 1024 started 2^30
invocations at the dispatch and ran out of memory, and the 32-bit product
of 65536 65536 1 wrapped to zero invocations.

A SPIR-V module now keeps the LocalSize of each GLCompute entry point, and
pipeline creation applies the same checks as for WGSL (zero, per
dimension, total invocations), as Rust wgpu-core does through naga's
SPIR-V frontend. The software backend counts invocations in 64 bits.
A raw SPIR-V module states array lengths with any constant, and the
interpreter materializes every constant composite and every Private,
Function and Workgroup variable as values, one 48-byte value per scalar or
vector. An OpConstantNull of array<f32, 0xFFFFFFFF> asked for 206 GB
inside ParseModule, at CreateShaderModule, and ended the process with a
fatal out of memory (P1-SW-040).

ParseModule now gives every type its natural byte size as it is declared,
with saturating arithmetic, and rejects the module when a type exceeds the
device's maxBufferSize (the largest thing WebGPU lets a shader address),
or when its Private variables exceed 64 KiB, the Function variables of one
function 64 KiB, its Workgroup variables (used or not, all allocated per
dispatch) 1 MiB, or its composite and null constants 1 MiB.

WebGPU sets no limit for these, so the software backend names its own
(shader.MaxPrivateBytes, MaxFunctionBytes, MaxWorkgroupBytes,
MaxConstantBytes): Private and Function are eight times the WGSL
guarantees of 8192 bytes, Workgroup 64 times the default
maxComputeWorkgroupStorageSize, which still bounds what an entry point
uses. ParseModuleLimits takes the limits; the software device passes its
adapter's. The naga snapshot corpus (149 modules, two SPIR-V option sets)
parses as before.
…pter

Replace the adapter-name exception for GitHub's macOS VM GPU with one
tolerance for every adapter: an interpolated BC1-BC5 palette entry may
differ from the software decode by two codes, endpoints by one, and -1, 0
and 1 are exact; a deviation above one code is logged with the adapter's
name. The specification's value is always accepted: Metal returns BC4/BC5
snorm endpoints as exactly k/127, which the software decoder (exact with
Intel Iris Xe) misses by 4e-6, and WARP returns -1 for the block whose
mode the Khronos spec leaves open. WARP and Intel return snorm endpoints
up to 2e-5 off k/127, so endpoints are not compared exactly.

"Between its endpoints", the old exception, admitted up to 255 codes on
that adapter, so a palette-order bug would have passed; and the virtual GPU
likely forwards to the host's Apple GPU, so real Apple hardware could
have failed what CI passed. The upload, copy and format bugs the Metal
comparison is for move endpoint texels, by tens of codes.
…bounds

Two more deep reviews of the bounds found modules they let through:
- an array of images, samplers, pointers, empty structs, void or function
  types counted 0 bytes per element and still allocated one value each
  (array<image, 2^32>, array<void, 2^32-1>);
- a type ID declared twice kept its old size in the composites sized from
  it while the interpreter used the new one;
- Workgroup memory is laid out by ArrayStride and Offset decorations, not
  by the budgeted natural size: a stride of 2^32-1 asked for 4 GB per
  dispatch, and a decoration may come after the variable;
- what an invocation holds live was not bounded at all: a value slot per
  ID up to the largest, the result of every typed instruction (OpUndef or
  OpLoad of a 256 MiB type), Uniform data, and Function variables along a
  call chain, times every invocation of a workgroup live at a barrier;
- two entry points of one name: the core's workgroup-size check could take
  the other one.

Every type now counts at least one 4-byte slot; a result ID defined twice,
two entry points of one name and recursion (SPIR-V 2.16.1) are rejected;
Workgroup memory is bounded after the parse by its laid-out size in 64
bits, so the ArrayStride Rust naga emits on workgroup arrays still parses;
one invocation may hold at most 256 MiB live, and compute pipeline
creation rejects a workgroup above 1 GiB. The core keeps the larger
workgroup when two compute entry points share a name. The naga snapshot
corpus and the tracked gg, g3d and gogpu shaders parse as before.

README, CHANGELOG and the compute backend docs list the limits and say
the software backend is not a sandbox for hostile SPIR-V: use WGSL for
untrusted shaders.
@kolkov
kolkov merged commit 804ce18 into main Nov 8, 2026
16 checks passed
@kolkov
kolkov deleted the release/v0.34.9 branch November 8, 2026 05:35
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